diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts new file mode 100644 index 000000000000..2190fd72bf01 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -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 { + /** Reactive source list. */ + source: ReadableAtom + /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ + also?: ReadableAtom[] + /** 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(cfg: PaneMirror): () => void { + const registered = new Map 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)) + } +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx new file mode 100644 index 000000000000..f0085597e45d --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -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( + () => ({ + $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 ( + + + 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} + /> + + + ) +} + +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 ( +
+
+
Couldn't open this session
+
{tile.error}
+ +
+
+ ) + } + + if (!runtimeId) { + // The SAME session loader the primary thread shows (Thread's + // loading === 'session' branch) — one loading language everywhere. + return ( +
+ +
+ ) + } + + return +} + +// --------------------------------------------------------------------------- +// 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) + +/** 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 ( + $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 ( + event.stopPropagation()}> + 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} + + + ) +} + +/** 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 ( + + {children} + + ) +} + +/** Keep pane contributions mirroring `$sessionTiles` (+ titles from + * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ +export const watchSessionTiles = paneMirror({ + 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 => , + tabWrap: (storedSessionId, tab) => ( + requestCloseSessionTile(storedSessionId)} + storedSessionId={storedSessionId} + tabPaneId={`session-tile:${storedSessionId}`} + > + {tab} + + ), + close: requestCloseSessionTile +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts new file mode 100644 index 000000000000..be5f2304819f --- /dev/null +++ b/apps/desktop/src/store/session-states.ts @@ -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>({}) + +/** 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 + +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 { + const byProfile: Record = {} + const parsed = readJson(TILES_KEY) + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + for (const [profile, list] of Object.entries(parsed as Record)) { + 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(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([...(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) { + 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 + /** Branch a stored session into a new chat (the sidebar's branch). */ + branchSession(storedSessionId: string): Promise + /** Delete a stored session (the sidebar's delete, incl. tile cleanup). */ + deleteSession(storedSessionId: string): Promise + /** 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 + /** Interrupt a tile's running turn. */ + interruptSession(runtimeId: string): Promise + /** 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 + /** Submit a prompt to a tile's live session. */ + submitToSession(runtimeId: string, text: string): Promise + /** 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 = {} +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:` 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).__HERMES_SESSION_TILES__ = { + close: closeSessionTile, + open: openSessionTile, + patch: patchSessionTile, + publish: publishSessionState, + states: () => $sessionStates.get(), + tiles: () => $sessionTiles.get() + } +}