diff --git a/apps/desktop/src/app/chat/pane-mirror.test.ts b/apps/desktop/src/app/chat/pane-mirror.test.ts new file mode 100644 index 0000000000..3b6ceacc07 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.test.ts @@ -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>> = [] +let sequence = 0 + +function setup(options: { + workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined) + workspaceOwnerKey?: string | ((tile: Tile) => string | undefined) +}) { + const source = atom([]) + const prefix = `pane-mirror-scope-${sequence++}` + cleanupSources.push(source) + + paneMirror({ + 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 + }) + }) +}) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 3a6995e1b4..2a2d5dfb48 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -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 = V | ((tile: T) => V | undefined) + +const workspaceValue = (value: WorkspaceValue | undefined, tile: T): V | undefined => + typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value + export interface PaneMirror { /** Reactive source list. */ source: ReadableAtom /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ also?: ReadableAtom[] + /** Workspace surface this tile belongs to. Omit for a global pane. */ + workspaceMode?: WorkspaceValue + /** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */ + workspaceOwnerKey?: WorkspaceValue /** 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 { /** 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(cfg: PaneMirror): () => void { - const registered = new Map 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(cfg: PaneMirror): () => 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(cfg: PaneMirror): () => 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)) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7051fbe80f..4b6db09d90 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -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 diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx index 1388f48c04..f2af03527a 100644 --- a/apps/desktop/src/app/chat/route-tile.tsx +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -86,6 +86,7 @@ function RouteTilePane({ path }: { path: string }) { /** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */ export const watchRouteTiles = paneMirror({ source: $routeTiles, + workspaceMode: 'sessions', key: t => t.path, prefix: 'route-tile', dir: t => t.dir, diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index d2113ada53..2adcc048a3 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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({ 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.) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 79ba22d817..2fe447b078 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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, diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index 998bc33af0..d2d8339f32 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -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') diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index 34b3278969..0781cf0013 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -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 } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index bc1e7b90ef..4e021c5873 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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 diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index e0a3772b85..1e97a661f3 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -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 } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx new file mode 100644 index 0000000000..310d85943b --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx @@ -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: () =>
{title} content
, + 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('[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() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('bots', 'connection-b::default')) + render() + expect(visibleTabs()).toEqual(['bot-b']) + expect(container?.textContent).toContain('Bot B content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('sessions')) + render() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts new file mode 100644 index 0000000000..7aae790a37 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -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 => ({ 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') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts new file mode 100644 index 0000000000..3980fc3f15 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -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('sessions') + +/** Default workspace owner key: none (unscoped / global ownership). */ +export const $workspaceOwnerKey = atom(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( + 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() + +/** 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() +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts index a5faa2f232..f39d987912 100644 --- a/apps/desktop/src/contrib/index.ts +++ b/apps/desktop/src/contrib/index.ts @@ -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' diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts index c46ee3e992..0958073bfa 100644 --- a/apps/desktop/src/contrib/types.ts +++ b/apps/desktop/src/contrib/types.ts @@ -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 ``; 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 } diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index f7f879d4e4..0817188888 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index c7e4328bd4..210448cef9 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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). */ diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 6ab9fa07fc..fd6f14e6ec 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -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) diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index b3173aacd0..3cf1752507 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -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({}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index b570a5056e..e2570c1bd2 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -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 +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 (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