Skip to content
Merged
80 changes: 80 additions & 0 deletions apps/desktop/src/app/chat/pane-mirror.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})
31 changes: 27 additions & 4 deletions apps/desktop/src/app/chat/pane-mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`. */
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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
}

Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/preview-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/route-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/app/chat/session-tile-owner-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'

import { describe, expect, it } from 'vitest'

const source = readFileSync(resolve(process.cwd(), 'src/app/chat/session-tile.tsx'), 'utf8')

describe('SessionTilePane owner-scoped listing', () => {
it('resolves a newly active tile on its persisted owner route', () => {
expect(source).toContain('void resolveStoredSession(storedSessionId, ownerRoute)')
expect(source).not.toMatch(/void resolveStoredSession\(storedSessionId\)\s*\n/)
})
})
45 changes: 33 additions & 12 deletions apps/desktop/src/app/chat/session-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ import {
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { requestForSessionProfile } from '@/store/session-request-router'
import {
$sessionStates,
$sessionTileDelegateRevision,
$sessionTiles,
closeSessionTile,
discardSessionTile,
patchSessionTile,
type SessionTile,
sessionTileDelegate
sessionTileDelegate,
sessionTileOwnerRoute
} from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'

Expand Down Expand Up @@ -135,7 +138,15 @@ function TileChat({
}) {
const { gateway, requestGateway } = useGatewayRequest()
const queryClient = useQueryClient()
const { selectModel } = useModelControls({ queryClient, requestGateway })
const ownerRoute = sessionTileOwnerRoute(storedSessionId)

const requestTileGateway = useCallback(
<T,>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal): Promise<T> =>
requestForSessionProfile<T>(ownerRoute, requestGateway, method, params, timeoutMs, signal),
[ownerRoute, requestGateway]
)

const { selectModel } = useModelControls({ queryClient, requestGateway: requestTileGateway })
const activeGatewayProfile = useStore($activeGatewayProfile)
const cwd = useStore(view.$cwd)
const gatewayOpen = useStore($gatewayState) === 'open'
Expand All @@ -160,7 +171,7 @@ function TileChat({
const composer = useComposerActions({
activeSessionId: runtimeId,
currentCwd: cwd,
requestGateway,
requestGateway: requestTileGateway,
scope: {
add: attachments.add,
remove: attachments.remove,
Expand Down Expand Up @@ -201,11 +212,11 @@ function TileChat({
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={selectModel}
profile={activeGatewayProfile}
requestGateway={requestGateway}
profile={ownerRoute?.profile || activeGatewayProfile}
requestGateway={requestTileGateway}
/>
) : null,
[activeGatewayProfile, gateway, gatewayOpen, requestGateway, selectModel]
[activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel]
)

return (
Expand Down Expand Up @@ -245,8 +256,10 @@ function TileChat({
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
const tiles = useStore($sessionTiles)
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
const ownerRoute = tile?.ownerRoute
const runtimeId = tile?.runtimeId ?? null
const gatewayOpen = useStore($gatewayState) === 'open'
const delegateRevision = useStore($sessionTileDelegateRevision)
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])

Expand Down Expand Up @@ -275,7 +288,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
return
}

void resolveStoredSession(storedSessionId)
void resolveStoredSession(storedSessionId, ownerRoute)
.then(resolved => {
if (cancelled || resolved || remaining <= 0) {
return
Expand All @@ -295,7 +308,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
window.clearTimeout(timer)
}
}
}, [hasMessages, runtimeId, storedSessionId])
}, [hasMessages, ownerRoute, runtimeId, storedSessionId])

// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
Expand Down Expand Up @@ -333,7 +346,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
.finally(() => {
resumingRef.current = false
})
}, [gatewayOpen, runtimeId, storedSessionId, tile?.error])
}, [delegateRevision, gatewayOpen, runtimeId, storedSessionId, tile?.error])

// The gateway (re)opening invalidates any latched error — it likely came
// from a not-yet-open gateway or the previous connection. Clearing it
Expand Down Expand Up @@ -401,15 +414,17 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined
* skipping the re-register that hands the tab back to this string. */
function tileTitle(storedSessionId: string): string {
const stored = tileStoredRow(storedSessionId)
const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle

return stored ? sessionTitle(stored) : NEW_SESSION_TITLE
return stored ? sessionTitle(stored) : explicit || NEW_SESSION_TITLE
}

/** The `@session` link payload for a tile tab drag — id + owning profile + title.
* Resolved at drag time, so an unsent tab drags under its draft name. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = tileStoredRow(storedSessionId)
const title = stored ? sessionTitle(stored) : draftTitleFor(storedSessionId) || NEW_SESSION_TITLE
const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle
const title = stored ? sessionTitle(stored) : explicit || draftTitleFor(storedSessionId) || NEW_SESSION_TITLE

return { id: storedSessionId, profile: stored?.profile ?? '', title }
}
Expand Down Expand Up @@ -595,6 +610,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.)
Expand All @@ -615,7 +632,11 @@ export const watchSessionTiles = paneMirror<SessionTile>({
),
// Until the first turn lists a row there is no title to register, so the tab
// takes its name from the composer instead — live, without re-registering.
tabTitle: storedSessionId => (tileStoredRow(storedSessionId) ? null : <SessionDraftTitle scope={storedSessionId} />),
tabTitle: storedSessionId =>
tileStoredRow(storedSessionId) ||
$sessionTiles.get().some(tile => tile.storedSessionId === storedSessionId && tile.workspaceTabTitle) ? null : (
<SessionDraftTitle scope={storedSessionId} />
),
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/contrib/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading