feat(desktop): multi-session tiles — per-profile state, tile pane, pane mirror

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent f1379bd6c2
commit e6fea77d14
3 changed files with 863 additions and 0 deletions

View file

@ -0,0 +1,104 @@
/**
* 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 } from 'react'
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
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
/** Center docks: the strip slot (stack before this pane id). */
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
/** 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: {
dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', pos: cfg.dir?.(tile) ?? 'right' },
minWidth: cfg.minWidth,
placement: 'main',
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))
}
}

View file

@ -0,0 +1,420 @@
/**
* SESSION TILES a stored session rendered as a layout-tree pane BESIDE the
* main thread (multi-session tiling). A tile IS the real chat surface: the
* same ChatView/ChatBar/Thread tree the primary session renders, mounted
* under a tile `SessionView` (its session's slice of `$sessionStates`) and a
* tile `ComposerScope` (own attachment chips, own focus-bus key). Actions
* (submit/slash/steer/edit/reload/restore/stop) come from
* `useSessionTileActions`, all writing through the wiring cache.
*
* Lifecycle: `openSessionTile(storedId)` -> `watchSessionTiles` registers a
* pane contribution docked right of the main zone -> tree adoption lands it
* -> the pane mounts and asks the delegate for a live runtime id. Closing
* the pane (tab Close) removes the tile + its zone; tiles persist across
* restarts and re-resume on boot.
*/
import { useStore } from '@nanostores/react'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import {
$sessionStates,
$sessionTiles,
closeSessionTile,
discardSessionTile,
patchSessionTile,
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser } from './thread-loading'
import { ChatView } from '.'
const NO_MESSAGES: ChatMessage[] = []
/** The tile's SessionView: the same atom shape the primary chat renders
* from, computed from this session's slice of `$sessionStates`. */
function buildTileView(storedSessionId: string): SessionView {
const $runtimeId = computed(
$sessionTiles,
tiles => tiles.find(t => t.storedSessionId === storedSessionId)?.runtimeId ?? null
)
const $state = computed([$runtimeId, $sessionStates], (runtimeId, states) =>
runtimeId ? states[runtimeId] : undefined
)
const $messages = computed($state, state => state?.messages ?? NO_MESSAGES)
return {
kind: 'tile',
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
$busy: computed($state, state => Boolean(state?.busy)),
$cwd: computed($state, state => state?.cwd ?? ''),
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
$messages,
$messagesEmpty: computed($messages, messages => messages.length === 0),
$model: computed($state, state => state?.model ?? ''),
$provider: computed($state, state => state?.provider ?? ''),
$runtimeId,
// Constant for the tile's lifetime — a plain atom, not a computed.
$storedId: atom(storedSessionId)
}
}
function TileChat({
runtimeId,
storedSessionId,
view
}: {
runtimeId: string
storedSessionId: string
view: SessionView
}) {
const { gatewayRef, requestGateway } = useGatewayRequest()
const cwd = useStore(view.$cwd)
// One attachment set + focus key per tile, stable for the tile's lifetime.
const attachments = useRef(createComposerAttachmentScope()).current
const scope = useMemo<ComposerScope>(
() => ({
$awaitingInput: sessionAwaitingInput(runtimeId),
attachments,
popoutAllowed: false,
readMessages: () => view.$messages.get(),
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]
)
const actions = useSessionTileActions({ runtimeId, scope, storedSessionId })
// The same attach/pick/paste/drop pipeline the primary composer uses,
// pointed at this tile's chips + session.
const composer = useComposerActions({
activeSessionId: runtimeId,
currentCwd: cwd,
requestGateway,
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
})
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
<ChatView
gateway={gatewayRef.current}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={() => undefined}
onCancel={actions.cancelRun}
onDeleteSelectedSession={() => undefined}
onDismissError={actions.dismissError}
onEdit={actions.editMessage}
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}
onReload={actions.reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={actions.restoreToMessage}
onRetryResume={() => patchSessionTile(storedSessionId, { error: undefined })}
onSteer={actions.steerPrompt}
onSubmit={actions.submitText}
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={() => undefined}
onTranscribeAudio={async audio => (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript}
/>
</ComposerScopeProvider>
</SessionViewProvider>
)
}
export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) {
const tiles = useStore($sessionTiles)
const tile = tiles.find(t => t.storedSessionId === storedSessionId)
const runtimeId = tile?.runtimeId ?? null
const gatewayOpen = useStore($gatewayState) === 'open'
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [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
// while it's still connecting — an ungated resume rejected there and
// latched every restored tile into the error card.
useEffect(() => {
if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) {
return
}
const delegate = sessionTileDelegate()
if (!delegate) {
return
}
resumingRef.current = true
delegate
.resumeTile(storedSessionId)
.then(id => patchSessionTile(storedSessionId, { error: undefined, runtimeId: id }))
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
// A gone session (404 / "Session not found") is terminal — a stale or
// cross-profile persisted tile. Discard it instead of latching an error
// that re-retries on every reconnect (the "Session not found" spam).
if (/session not found|\b404\b/i.test(message)) {
discardSessionTile(storedSessionId)
} else {
patchSessionTile(storedSessionId, { error: message })
}
})
.finally(() => {
resumingRef.current = false
})
}, [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
// retriggers the resume effect: one bounded auto-retry per (re)connect,
// mirroring the primary path's became-open resync.
useEffect(() => {
if (gatewayOpen && tile?.error) {
patchSessionTile(storedSessionId, { error: undefined })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gatewayOpen, storedSessionId])
if (tile?.error) {
return (
<div className="grid h-full place-items-center p-4">
<div className="max-w-[24rem] space-y-2 text-center font-mono text-[11px]">
<div className="text-(--ui-danger,#f87171)">Couldn't open this session</div>
<div className="break-words text-(--ui-text-quaternary)">{tile.error}</div>
<Button onClick={() => patchSessionTile(storedSessionId, { error: undefined })} size="sm" variant="outline">
Retry
</Button>
</div>
</div>
)
}
if (!runtimeId) {
// The SAME session loader the primary thread shows (Thread's
// loading === 'session' branch) — one loading language everywhere.
return (
<div className="relative h-full">
<CenteredThreadSpinner />
</div>
)
}
return <TileChat runtimeId={runtimeId} storedSessionId={storedSessionId} view={view} />
}
// ---------------------------------------------------------------------------
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
return stored ? sessionTitle(stored) : 'Session'
}
// ---------------------------------------------------------------------------
// Close confirmation — a BUSY tab (streaming, or blocked on clarify/approval
// input) doesn't close silently.
// ---------------------------------------------------------------------------
/** Stored id awaiting close confirmation (null = no dialog). */
const $confirmCloseTile = atom<null | string>(null)
/** The tile closer, gated: a quiet session closes immediately; a busy or
* input-blocked one asks first. One state read the tile's runtime slice. */
export function requestCloseSessionTile(storedSessionId: string): void {
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined
if (state?.busy || state?.awaitingResponse || state?.needsInput) {
$confirmCloseTile.set(storedSessionId)
} else {
closeSessionTile(storedSessionId)
}
}
/** Mounted once at the shell root: the "Close running tab?" confirmation. */
export function SessionTileCloseConfirm() {
const { t } = useI18n()
const storedSessionId = useStore($confirmCloseTile)
return (
<ConfirmDialog
confirmLabel={t.zones.closeRunningConfirm}
description={t.zones.closeRunningBody}
destructive
onClose={() => $confirmCloseTile.set(null)}
onConfirm={() => {
if (storedSessionId) {
closeSessionTile(storedSessionId)
}
}}
open={storedSessionId !== null}
title={t.zones.closeRunningTitle}
/>
)
}
/** Layout reset every session tile collapses into the MAIN zone as a tab
* after the workspace (the primary session stays the first tab), the "smart"
* reset: N scattered tiles become one tab bar over the chat instead of
* re-docking to their old edges.
*
* Runs BEFORE generic adoption (see registerLayoutResetHandler) the tiles
* aren't in the fresh tree yet, so each `moveTreePane` ADDS the tile into the
* workspace group as a tab (append). The main group id is re-read each pass
* because appending returns a new tree. */
export function stackSessionTilesIntoMain(): void {
for (const tile of $sessionTiles.get()) {
const tree = $layoutTree.get()
const mainGroup = tree ? findGroupOfPane(tree, 'workspace')?.id : null
if (mainGroup) {
moveTreePane(`session-tile:${tile.storedSessionId}`, { groupId: mainGroup, pos: 'center' })
}
}
}
/** A session TAB's context menu: the full session verb set (pin, copy id, new
* window, branch, rename, archive, delete) the SAME menu a sidebar row
* gets, targeted through the tile delegate (whose verbs are generic over
* stored ids, primary included). The wrapper stops the contextmenu from also
* opening the zone strip's menu. Shared by tile tabs AND the main tab. */
export function SessionTabMenu({
children,
onClose,
onHideTabBar,
storedSessionId,
tabPaneId
}: {
children: React.ReactElement
/** Close this tab (tiles; the main tab passes nothing). */
onClose?: () => void
/** Hide the zone's tab bar (main tab only — the sticky bar's off switch). */
onHideTabBar?: () => void
storedSessionId: string
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
return (
<span className="contents" onContextMenu={event => event.stopPropagation()}>
<SessionContextMenu
onArchive={() => void sessionTileDelegate()?.archiveSession(storedSessionId)}
onBranch={() => void sessionTileDelegate()?.branchSession(storedSessionId)}
onClose={onClose}
onDelete={() => void sessionTileDelegate()?.deleteSession(storedSessionId)}
onHideTabBar={onHideTabBar}
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
pinned={pinned}
profile={stored?.profile}
sessionId={storedSessionId}
surface="tab"
tabPaneId={tabPaneId}
title={tileTitle(storedSessionId)}
>
{children}
</SessionContextMenu>
</span>
)
}
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
* session, plus the bar's off switch (the bar sticky-shows once a tab is
* ever gained; this is the explicit way back). A fresh draft has no session
* no menu. */
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
const selected = useStore($selectedStoredSessionId)
const hideTabBar = () => {
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, 'workspace') : null
if (group) {
setTreeGroupHeaderHidden(group.id, true)
}
}
if (!selected) {
return children
}
return (
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
{children}
</SessionTabMenu>
)
}
/** Keep pane contributions mirroring `$sessionTiles` (+ titles from
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
anchor: t => t.anchor,
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu
onClose={() => requestCloseSessionTile(storedSessionId)}
storedSessionId={storedSessionId}
tabPaneId={`session-tile:${storedSessionId}`}
>
{tab}
</SessionTabMenu>
),
close: requestCloseSessionTile
})

View file

@ -0,0 +1,339 @@
/**
* MULTI-SESSION VIEW STATE the reactive face of the per-runtime session
* cache (`sessionStateByRuntimeIdRef` in use-session-state-cache).
*
* The cache already ingests EVERY session's gateway events; only the view
* was single-session ($messages + the active-id gate). This store mirrors
* the cache per runtime id so any number of surfaces (session tiles, future
* pane windows) can each subscribe to one session's state without touching
* the main chat's `$messages` pipeline same pattern as `useSessionSlice`
* over `$todosBySession`, applied to whole `ClientSessionState`s.
*
* TILES are the first consumer: sessions opened side-by-side with the main
* thread, each in its own layout-tree pane. `$sessionTiles` holds the
* stored-session ids (persisted tiles survive restarts); the wiring layer
* owns resume/submit (it has the gateway + cache internals) and registers
* itself here as the delegate so tile UI stays dependency-light.
*/
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
import { findGroup } from '@/components/pane-shell/tree/model'
import { $activeTreeGroup, $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store'
import { readJson, writeJson } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
import { $activeSessionId, $selectedStoredSessionId } from './session'
// ---------------------------------------------------------------------------
// Reactive per-runtime session state (view mirror of the wiring cache).
// ---------------------------------------------------------------------------
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
/** Publish one session's state (immutable per-key — slices stay stable). */
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
}
export function dropSessionState(runtimeId: string) {
const current = $sessionStates.get()
if (!(runtimeId in current)) {
return
}
const { [runtimeId]: _dropped, ...rest } = current
$sessionStates.set(rest)
}
// ---------------------------------------------------------------------------
// Session tiles.
// ---------------------------------------------------------------------------
/** Edge a tile docks against main when it first joins the tree. Shared by
* session tiles and route (page) tiles. */
export type SplitDir = 'bottom' | 'left' | 'right' | 'top'
/** Where a tile lands on adoption: an edge split, or `center` = stack into
* the anchor's zone as a tab (a drop on the zone's tab strip). */
export type TileDock = 'center' | SplitDir
export interface SessionTile {
/** Stored session id — the durable identity (runtime ids are ephemeral). */
storedSessionId: string
/** Dock against `anchor` on adoption (default right; center = stack). */
dir?: TileDock
/** Pane to dock against (a drop's target zone) default the workspace.
* In-memory only: after first adoption the tree remembers placement. */
anchor?: string
/** Center docks: stack BEFORE this pane id (`null`/omitted = append)
* the strip divider's slot. In-memory, like `anchor`. */
before?: null | string
/** Live runtime id once the tile's resume has bound one. */
runtimeId?: string
/** Resume failed terminally (shown in the tile; retryable). */
error?: string
}
// Tiles are persisted PER PROFILE: a session belongs to one profile, and the
// single live gateway is scoped to one profile at a time, so a tile only makes
// sense while its profile is active. Switching profiles swaps the visible set
// (and drops runtime bindings so each tile re-resumes against the now-current
// gateway — which also settles the "tile resumes against the wrong backend" and
// "stale runtime after respawn" bugs by construction).
const TILES_KEY = 'hermes.desktop.sessionTiles.v2'
const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1'
type StoredTile = Pick<SessionTile, 'dir' | 'storedSessionId'>
const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId })
function parseTileList(value: unknown): StoredTile[] {
return Array.isArray(value)
? value
.filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string'))
.map(toStored)
: []
}
function loadTilesByProfile(): Record<string, StoredTile[]> {
const byProfile: Record<string, StoredTile[]> = {}
const parsed = readJson<unknown>(TILES_KEY)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [profile, list] of Object.entries(parsed as Record<string, unknown>)) {
const tiles = parseTileList(list)
if (tiles.length > 0) {
byProfile[normalizeProfileKey(profile)] = tiles
}
}
}
// Migrate a v1 flat list into the default profile, then retire the key.
const legacy = parseTileList(readJson<unknown>(LEGACY_TILES_KEY))
if (legacy.length > 0) {
const key = normalizeProfileKey('default')
byProfile[key] = [...(byProfile[key] ?? []), ...legacy]
}
writeJson(LEGACY_TILES_KEY, null)
return byProfile
}
const tilesByProfile = loadTilesByProfile()
// Keyed by the GATEWAY profile: the rail's profile switch is a soft swap
// ($activeGatewayProfile moves, no reload) — $activeProfile mirrors the
// window's primary backend and never changes on a rail switch, so keying on
// it left the previous profile's tiles registered (phantom "Session" tabs).
const profileKey = () => normalizeProfileKey($activeGatewayProfile.get())
// Runtime ids are process-scoped — never trust a persisted one, so the live
// atom hydrates from the stored (runtime-less) tiles for the active profile.
export const $sessionTiles = atom<SessionTile[]>([...(tilesByProfile[profileKey()] ?? [])])
function persistTiles() {
writeJson(TILES_KEY, Object.keys(tilesByProfile).length === 0 ? null : tilesByProfile)
}
function saveTiles(tiles: SessionTile[]) {
$sessionTiles.set(tiles)
const stored = tiles.map(toStored)
if (stored.length > 0) {
tilesByProfile[profileKey()] = stored
} else {
delete tilesByProfile[profileKey()]
}
persistTiles()
}
// Profile switch: surface the new profile's tiles with runtime ids cleared so
// they re-resume against the now-current gateway. (Fires immediately on
// subscribe; harmless — the init value already matches.)
$activeGatewayProfile.subscribe(() => {
$sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])])
})
export function patchSessionTile(storedSessionId: string, patch: Partial<SessionTile>) {
saveTiles($sessionTiles.get().map(t => (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t)))
}
/** Drop live runtime bindings so every tile re-resumes used on gateway
* reconnect, where a respawned backend re-mints (recycles) runtime ids. */
export function resetTileRuntimeBindings() {
const tiles = $sessionTiles.get()
if (tiles.some(t => t.runtimeId)) {
$sessionTiles.set(tiles.map(toStored))
}
}
// ---------------------------------------------------------------------------
// Delegate — the wiring layer (which owns the gateway + session cache) plugs
// its actions in; tile UI calls through here. Same inversion as the tree
// store's pane closers.
// ---------------------------------------------------------------------------
export interface SessionTileDelegate {
/** Archive a stored session (the sidebar's archive, incl. tile cleanup). */
archiveSession(storedSessionId: string): Promise<void>
/** Branch a stored session into a new chat (the sidebar's branch). */
branchSession(storedSessionId: string): Promise<void>
/** Delete a stored session (the sidebar's delete, incl. tile cleanup). */
deleteSession(storedSessionId: string): Promise<void>
/** Run a slash command against a tile's session (app-level effects e.g.
* branch/handoff act on the main surface, as they should). */
executeSlash(rawCommand: string, sessionId: string): Promise<void>
/** Interrupt a tile's running turn. */
interruptSession(runtimeId: string): Promise<void>
/** Bind a live runtime id for a stored session (resume without touching
* the main view). Returns the runtime id, or throws. */
resumeTile(storedSessionId: string): Promise<string>
/** Submit a prompt to a tile's live session. */
submitToSession(runtimeId: string, text: string): Promise<void>
/** THE session-state write path routes through the wiring cache so the
* cache, the primary view (when active), and every tile mirror agree. */
updateSession(runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState): ClientSessionState
}
let delegate: SessionTileDelegate | null = null
export function setSessionTileDelegate(next: SessionTileDelegate) {
delegate = next
}
export function sessionTileDelegate(): SessionTileDelegate | null {
return delegate
}
/** Open (or front) a tile for a stored session, docked on `dir` (default
* right; `center` = stack into the anchor's zone, `before` = strip slot).
* Idempotent an already-open tile keeps its original placement. The
* session LOADED IN MAIN never opens as a tile (same transcript twice,
* fighting over one runtime silly). */
export function openSessionTile(storedSessionId: string, dir: TileDock = 'right', anchor?: string, before?: null | string) {
const tiles = $sessionTiles.get()
if (storedSessionId === $selectedStoredSessionId.get()) {
return
}
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
saveTiles([...tiles, { anchor, before, dir, storedSessionId }])
}
}
// Closed-tab stack for ⌘⇧T reopen (in-memory) — keyed PER PROFILE like the
// tiles themselves, so ⌘⇧T after a profile switch never resurrects the other
// profile's session. The tile's placement is remembered so it returns in place.
const closedTilesByProfile: Record<string, SessionTile[]> = {}
const closedStack = (): SessionTile[] => (closedTilesByProfile[profileKey()] ??= [])
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 })
}
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
}
/** Drop a DEAD tile a persisted tile whose session no longer exists on the
* backend (resume 404s). Unlike close, it leaves no T undo (resurrecting it
* would just 404 again) and evicts any cached state. This is what clears the
* "Session not found" resume spam from stale/cross-profile persisted tiles. */
export function discardSessionTile(storedSessionId: string) {
const runtimeId = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)?.runtimeId
if (runtimeId) {
dropSessionState(runtimeId)
}
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
}
/** T reopen the most recently closed tab where it was. Skips ids that are
* live again (reopened, or now the primary). */
export function reopenLastClosedTile(): void {
const stack = closedStack()
for (let tile = stack.pop(); tile; tile = stack.pop()) {
const { storedSessionId } = tile
if (storedSessionId === $selectedStoredSessionId.get()) {
continue
}
if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
return
}
}
}
// ---------------------------------------------------------------------------
// The FOCUSED session — one derivation, not another hand-maintained
// "$activeSession" sibling. The layout's interaction tracker ($activeTreeGroup:
// last click/focus, the same source ⌘W uses) resolves to a zone; its active
// pane names the session: a `session-tile:<storedId>` pane IS that session,
// anything else falls back to the route-driven primary. Chrome that should
// follow the user between tiles (titlebar session title, statusbar context /
// timer / model) reads these instead of the primary-only atoms.
// ---------------------------------------------------------------------------
const TILE_PANE_PREFIX = 'session-tile:'
/** Stored id of the focused session (the interacted zone's tile, else the
* primary's selection). Null on a fresh draft. */
export const $focusedStoredSessionId = computed(
[$activeTreeGroup, $layoutTree, $selectedStoredSessionId],
(groupId, tree, selected) => {
const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined
return active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected
}
)
/** Live runtime id of the focused session (a tile's bound runtime, else the
* primary's active session). */
export const $focusedRuntimeId = computed(
[$focusedStoredSessionId, $selectedStoredSessionId, $activeSessionId, $sessionTiles],
(focused, selected, primaryRuntime, tiles) => {
if (focused && focused !== selected) {
return tiles.find(t => t.storedSessionId === focused)?.runtimeId ?? null
}
return primaryRuntime
}
)
/** The focused session's state slice (undefined while unresolved/unbound). */
export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates], (runtimeId, states) =>
runtimeId ? states[runtimeId] : undefined
)
// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus
// home to the workspace — a previously-clicked tile must not keep owning the
// titlebar/statusbar readouts for a session switch it had no part in.
$selectedStoredSessionId.listen(() => noteActiveTreeGroup(null))
// Dev hook for automation (mirrors __HERMES_LAYOUT_TREE__).
if (import.meta.env.DEV && typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__HERMES_SESSION_TILES__ = {
close: closeSessionTile,
open: openSessionTile,
patch: patchSessionTile,
publish: publishSessionState,
states: () => $sessionStates.get(),
tiles: () => $sessionTiles.get()
}
}