Files
hermes-agent/apps/desktop/src/app/chat/pane-mirror.ts
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

143 lines
6.0 KiB
TypeScript

/**
* Mirror a reactive list of "tiles" into layout-tree pane contributions:
* register a pane per tile, refresh its title in place, and dispose panes whose
* tile is gone. This is the shared bookkeeping — a keyed registry, a wanted-set
* diff, a one-time pane closer — behind BOTH session tiles and route (page)
* tiles; each supplies only what differs (key, title, render, close, edge).
*/
import type { ReadableAtom } from 'nanostores'
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
import type { MenuKit } from '@/components/ui/actions-menu'
import { registry } from '@/contrib/registry'
import type { TileDock } from '@/store/session-states'
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>[]
/** Stable key + pane-id seed for a tile. */
key: (tile: T) => string
/** Pane-id namespace — the id is `${prefix}:${key}`. */
prefix: string
/** Dock on adoption (default right; `center` = stack into anchor's zone). */
dir?: (tile: T) => TileDock | undefined
/** Pane to dock against (default `workspace`) — a drop's target zone. */
anchor?: (tile: T) => string | undefined
/** Where a top-edge zone should render its tab strip. */
titlebar?: 'content' | 'window'
/** Background surface for a zone containing only these panes. */
headerSurface?: 'chat' | 'sidebar'
/** Center docks: the strip slot (stack before this pane id). */
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
/** Custom lead NODE for the tile's tab (rendered before the label). A live,
* self-subscribing component (e.g. a session's status dot) so the strip needn't
* re-sync on status/color change — only `title` drives re-registration. */
tabLead?: (key: string) => ReactNode
/** Custom label NODE for the tile's tab, self-subscribing for the same reason
* as `tabLead` — a name that moves faster than re-registration (see
* PaneChrome.tabTitle). Falls back to `title`. */
tabTitle?: (key: string) => ReactNode
/** Mint another tile of this kind — the strip's "+" (see PaneChrome.newTab).
* Per tile so a mirror can offer it for some of its tabs and not others. */
newTab?: (key: string) => (() => void) | undefined
render: (key: string) => ReactNode
/** Extra rows at the top of the zone tab menu (see PaneChrome.tabMenuPrefix). */
tabMenuPrefix?: (key: string) => ((kit: MenuKit) => ReactNode) | undefined
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
/** Override the tile's TAB drag (session drop language: stack/split/link).
* Returns whether it took the drag (see PaneChrome.tabDrag). */
tabDrag?: (key: string, event: ReactPointerEvent<HTMLElement>, onTap: () => void) => boolean
/** Wired as the pane's closer (tab Close). */
close: (key: string) => void
}
/** 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 paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
const tiles = cfg.source.get()
const wanted = new Set(tiles.map(cfg.key))
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
continue
}
const dispose = registry.register({
id: paneId(key),
area: 'panes',
title,
data: {
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
tabTitle: cfg.tabTitle ? () => cfg.tabTitle!(key) : undefined,
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
pos: cfg.dir?.(tile) ?? 'right'
},
minWidth: cfg.minWidth,
newTab: cfg.newTab?.(key),
headerSurface: cfg.headerSurface,
titlebar: cfg.titlebar,
// Every mirrored tile is a full workspace surface docked beside main —
// and closeable, which is what keeps its tab when it lands in a zone of
// its own (see strip-visibility.ts).
placement: 'main',
tabDrag: cfg.tabDrag
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => cfg.tabDrag!(key, event, onTap)
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
tabMenuPrefix: cfg.tabMenuPrefix?.(key),
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
},
render: () => cfg.render(key)
})
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))
}
}
for (const [key, entry] of registered) {
if (!wanted.has(key)) {
entry.dispose()
registered.delete(key)
removeTreePane(paneId(key))
}
}
// Prune tree panes the SHARED tree persisted for a tile we never registered
// this session and that isn't wanted now — a profile switch reloads with the
// other profile's tile panes still stacked in. (`registered` is empty after a
// reload, so the loop above can't catch these.)
for (const id of treePanesWithPrefix(`${cfg.prefix}:`)) {
if (!wanted.has(id.slice(cfg.prefix.length + 1))) {
removeTreePane(id)
}
}
}
return () => {
sync()
cfg.source.listen(sync)
cfg.also?.forEach(atom => atom.listen(sync))
}
}