mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): multi-session tiles — per-profile state, tile pane, pane mirror
This commit is contained in:
parent
f1379bd6c2
commit
e6fea77d14
3 changed files with 863 additions and 0 deletions
104
apps/desktop/src/app/chat/pane-mirror.ts
Normal file
104
apps/desktop/src/app/chat/pane-mirror.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
420
apps/desktop/src/app/chat/session-tile.tsx
Normal file
420
apps/desktop/src/app/chat/session-tile.tsx
Normal 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
|
||||
})
|
||||
339
apps/desktop/src/store/session-states.ts
Normal file
339
apps/desktop/src/store/session-states.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue