Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
import {
$attentionSessionIds,
$sessionStates,
$workingSessionIds,
clearAllSessionStates,
publishSessionState
} from '@/store/session-states'
import { rehydrateLiveSessionStatuses } from './use-background-sync'
/**
* `session.active_list` is the authoritative snapshot of what is RUNNING in the
* polled gateway process. A session that finished while Desktop was looking
* elsewhere — or whose runtime id was recycled by a backend respawn — simply
* stops appearing in the response. Absence is therefore a completion signal,
* not "no news": if nothing reaps it, the row spins forever and the
* busy→idle edge that paints the green "your turn" dot never fires.
*/
describe('rehydrateLiveSessionStatuses — reaping vanished runtimes', () => {
beforeEach(() => {
vi.useFakeTimers()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$activeSessionId.set(null)
})
it('clears a working session that disappears from the live snapshot', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-a', session_key: 'stored-a', status: 'working' }]
})
expect($workingSessionIds.get()).toEqual(['stored-a'])
// The turn finished and the gateway reaped the session between polls.
rehydrateLiveSessionStatuses({ sessions: [] })
expect($workingSessionIds.get()).toEqual([])
})
it('fires the unread "your turn" marker for a vanished background session', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-b', session_key: 'stored-b', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
expect($unreadFinishedSessionIds.get()).toEqual(['stored-b'])
})
it('clears a blocked session that disappears from the live snapshot', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-c', session_key: 'stored-c', status: 'waiting' }]
})
expect($attentionSessionIds.get()).toEqual(['stored-c'])
rehydrateLiveSessionStatuses({ sessions: [] })
expect($attentionSessionIds.get()).toEqual([])
})
it('leaves runtimes this poll never seeded alone', () => {
// A background PROFILE's sessions are served by a different gateway and
// never appear in this profile's active_list. Reaping them would dark out
// every other profile's running rows.
rehydrateLiveSessionStatuses(
{ sessions: [{ id: 'runtime-other', session_key: 'stored-other', status: 'working' }] },
Date.now(),
'other'
)
rehydrateLiveSessionStatuses({ sessions: [] }, Date.now(), 'default')
expect($workingSessionIds.get()).toEqual(['stored-other'])
})
it('seals open tool parts and clears awaitingResponse when a session vanishes', () => {
const openTool = {
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'patch',
args: {},
argsText: '{}'
} as never
publishSessionState('runtime-tools', {
...createClientSessionState('stored-tools'),
busy: true,
awaitingResponse: true,
messages: [{ id: 'a1', role: 'assistant', parts: [openTool], pending: false } as never]
})
// Keep the runtime referenced so the settled state stays in the store
// instead of being evicted as no-longer-needed.
$activeSessionId.set('runtime-tools')
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-tools', session_key: 'stored-tools', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
const state = $sessionStates.get()['runtime-tools']
expect(state.busy).toBe(false)
expect(state.awaitingResponse).toBe(false)
expect((state.messages[0].parts[0] as { result?: unknown }).result).toBeDefined()
})
it('clears a session stuck awaiting a response without the busy flag', () => {
publishSessionState('runtime-await', {
...createClientSessionState('stored-await'),
awaitingResponse: true,
busy: false
})
$activeSessionId.set('runtime-await')
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-await', session_key: 'stored-await', status: 'working' }]
})
rehydrateLiveSessionStatuses({ sessions: [] })
expect($sessionStates.get()['runtime-await'].awaitingResponse).toBe(false)
})
})
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
import { $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
import { rehydrateLiveSessionStatuses, resetLiveRuntimeTracking } from './use-background-sync'
/**
* (C) The sidebar spinner is driven by `$workingSessionIds`, which is keyed by
* STORED session id. A turn that STARTS while Desktop isn't receiving stream
* events — a background profile, a degraded remote socket, a session opened on
* another surface — is only ever learned about through the `session.active_list`
* poll. If that poll can't seed a row the renderer has never seen, the thread
* name never gets its arc even though the backend is plainly working.
*/
describe('rehydrateLiveSessionStatuses — seeding a turn the renderer never saw start', () => {
beforeEach(() => {
vi.useFakeTimers()
$selectedStoredSessionId.set(null)
$unreadFinishedSessionIds.set([])
resetLiveRuntimeTracking()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
clearAllSessionStates()
resetLiveRuntimeTracking()
$unreadFinishedSessionIds.set([])
})
it('shows the spinner for a turn that started with no stream events', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('keeps the spinner across polls while the turn is still running', () => {
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-cold', session_key: 'stored-cold', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-cold')
})
it('shows the spinner when a runtime id is recycled onto a new stored session', () => {
// A respawned backend can mint the same runtime id for a different stored
// session. The row for the NEW stored id must light up, not the stale one.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-old', status: 'working' }]
})
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-1', session_key: 'stored-new', status: 'working' }]
})
expect($workingSessionIds.get()).toContain('stored-new')
expect($workingSessionIds.get()).not.toContain('stored-old')
})
it('leaves a starting session idle — the agent build is not proof of a turn', () => {
// `starting` = `agent_build_started` without `agent_ready`. _start_agent_build
// runs on the first prompt OR any incidental RPC that needs the agent, so it
// is not proof of a turn — lighting the spinner here would fire on merely
// opening a session. A real turn arrives as `working`.
rehydrateLiveSessionStatuses({
sessions: [{ id: 'runtime-boot', session_key: 'stored-boot', status: 'starting' }]
})
expect($workingSessionIds.get()).not.toContain('stored-boot')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $activeSessionId } from '@/store/session'
import { useBackgroundSync } from './use-background-sync'
const noop = () => undefined
const requestGateway = async () => ({ sessions: [] })
function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise<void>) {
return renderHook(
({ connectionId, profile }: { connectionId: string; profile: string }) => {
useBackgroundSync({
activeConnectionId: connectionId,
activeGatewayProfile: profile,
activeIsMessaging: false,
activeSessionId: null,
activeStoredSessionId: null,
freshDraftReady: false,
gatewayState: 'open',
refreshActiveTranscript: noop,
refreshCronJobs: noop,
refreshCurrentModel: noop,
refreshHermesConfig: noop,
refreshMessagingSessions: noop,
refreshSessions,
requestGateway
})
},
{ initialProps: { connectionId: activeConnectionId, profile: activeGatewayProfile } }
)
}
describe('useBackgroundSync profile-scoped session refresh', () => {
beforeEach(() => {
vi.useFakeTimers()
$activeSessionId.set(null)
$changeEventsAvailable.set(false)
$cronChangeTick.set(0)
$sessionsChangeTick.set(0)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('refreshes the session list after the active gateway profile changes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const hook = render('default', 'local', refreshSessions)
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
refreshSessions.mockClear()
hook.rerender({ connectionId: 'local', profile: 'nova' })
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
it('refreshes the session list when the backend changes but the profile name does not', async () => {
const refreshSessions = vi.fn(async () => undefined)
const hook = render('default', 'work', refreshSessions)
await act(async () => undefined)
refreshSessions.mockClear()
hook.rerender({ connectionId: 'homelab', profile: 'default' })
await act(async () => undefined)
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,912 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import { getLatestSessionMessages, type ProfileScope } from '@/hermes'
import { preserveLocalAssistantErrors, sealOpenToolParts, toChatMessages } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery, batteryPollInterval } from '@/store/power'
import { refreshActiveProfile } from '@/store/profile'
import { refreshProjectTree } from '@/store/projects'
import {
$activeSessionId,
$busy,
$currentCwd,
$selectedStoredSessionId,
getSessionOwnerHint,
ownerLookupSessionRows,
sessionMatchesStoredId,
setCurrentCwd
} from '@/store/session'
import type { SessionProfileRoute } from '@/store/session-request-router'
import {
$sessionStates,
$sessionTiles,
publishSessionState,
SESSION_WATCHDOG_TIMEOUT_MS,
setSessionStalled
} from '@/store/session-states'
import type { ClientSessionState } from '../../types'
import type { GatewayRequester } from '../types'
interface ActiveTranscriptSession {
ownerRoute?: SessionProfileRoute
profile?: string | null
}
/** Resolve an active transcript from visible rows or its unique hidden owner. */
export function resolveActiveTranscriptSession(storedSessionId: string): ActiveTranscriptSession | undefined {
const visible = ownerLookupSessionRows().find(session => sessionMatchesStoredId(session, storedSessionId))
if (visible) {
return { profile: visible.profile }
}
const ownerRoute = getSessionOwnerHint(storedSessionId)
return ownerRoute ? { ownerRoute, profile: ownerRoute.profile } : undefined
}
export interface ActiveTranscriptRefreshDeps {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
requestSequenceRef: MutableRefObject<number>
selectedStoredSessionIdRef: MutableRefObject<string | null>
resolveSession: (storedSessionId: string) => ActiveTranscriptSession | null | undefined
signatureRef: MutableRefObject<Map<string, string>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
function tileRuntimeOwnsLiveState(runtimeId: string): boolean {
const state = $sessionStates.get()[runtimeId]
return Boolean(state && (state.busy || state.awaitingResponse || state.needsInput || state.turnLive))
}
type TileTranscriptTarget = { ownerRoute?: SessionProfileRoute; storedSessionId: string; runtimeId?: string }
/** Signature key per tile — carries the owner route so two connections/profiles
* sharing a stored id (or a tile re-homed to another owner) never alias. */
function tileTranscriptSignatureKey(tile: TileTranscriptTarget): string {
const route = tile.ownerRoute
return `tile:${route ? `${route.connectionId}:${route.targetProfile ?? route.profile}:` : ''}${tile.storedSessionId}`
}
/**
* Reconcile the persisted transcripts of every open WORKSPACE TILE (#93942
* slice 1). Bot canonical chats live here — never in $sessions /
* $messagingSessions (they carry the core `hidden` flag), so the main-pane
* reconcile path's resolveSession() bails on them and a background delivery
* never reaches an open bot chat. Each tile carries its own stored↔runtime id
* pair, so no resolution step is needed; refreshes are signature-gated per
* tile so a no-change event costs nothing, and a busy tile is skipped (its own
* stream owns the view while streaming).
*
* Sequencing note (#94255 review): all tiles SHARE one request sequence, so a
* second tick arriving mid-read invalidates every in-flight read from the
* first (latest-wins — same discipline as the main pane path). Under rapid
* tick bursts only the final tick lands updates; that is intended, since each
* tick re-reads from storage anyway.
*/
export async function reconcileTileTranscripts({
requestSequenceRef,
signatureRef,
updateSessionState,
tiles: tilesOverride
}: {
requestSequenceRef: MutableRefObject<number>
signatureRef: MutableRefObject<Map<string, string>>
tiles?: TileTranscriptTarget[]
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}): Promise<void> {
const tiles = tilesOverride ?? $sessionTiles.get()
const openSignatureKeys = new Set(tiles.map(tileTranscriptSignatureKey))
for (const signatureKey of signatureRef.current.keys()) {
if (!openSignatureKeys.has(signatureKey)) {
signatureRef.current.delete(signatureKey)
}
}
for (const tile of tiles) {
const storedSessionId = tile.storedSessionId
const runtimeSessionId = tile.runtimeId
if (!runtimeSessionId) {
// Resume not yet bound — the tile's own stream owns the view.
continue
}
if (!storedSessionId || !runtimeSessionId || tileRuntimeOwnsLiveState(runtimeSessionId)) {
continue
}
if ($activeSessionId.get() === runtimeSessionId) {
// The main pane reconcile already owns this surface.
continue
}
const requestId = ++requestSequenceRef.current
// With a tiles override (test path), the live $sessionTiles check can't
// see the synthetic tile — treat override tiles as present.
const tileStillPresent = () =>
tilesOverride
? tilesOverride.some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId)
: $sessionTiles.get().some(t => t.storedSessionId === storedSessionId && t.runtimeId === runtimeSessionId)
// Bot tiles are pinned to an exact owner (connection + target profile);
// read from that backend, not whichever profile is foreground. Tiles
// without a route keep the legacy local read.
const profileScope: ProfileScope = tile.ownerRoute
? {
connectionId: tile.ownerRoute.connectionId,
profile: tile.ownerRoute.targetProfile ?? tile.ownerRoute.profile
}
: undefined
const signatureKey = tileTranscriptSignatureKey(tile)
try {
const latest = await getLatestSessionMessages(storedSessionId, profileScope)
if (
requestId !== requestSequenceRef.current ||
tileRuntimeOwnsLiveState(runtimeSessionId) ||
!tileStillPresent()
) {
// Tile closed or superseded mid-read — discard AND prune its
// signature so the map doesn't grow one entry per ever-opened tile
// for the app's lifetime (#94255 review point 3).
signatureRef.current.delete(signatureKey)
continue
}
const signature = sessionMessagesSignature(latest.messages)
if (signatureRef.current.get(signatureKey) === signature) {
continue
}
signatureRef.current.set(signatureKey, signature)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: preserveLocalAssistantErrors(
graftRefreshedTailOntoBackfill(messages, state.messages),
state.messages
)
}),
storedSessionId
)
} catch {
// Non-fatal: the next change event retries.
}
}
}
/** Reconcile one persisted transcript snapshot into the currently viewed session. */
export async function reconcileActiveTranscript({
activeSessionIdRef,
busyRef,
requestSequenceRef,
resolveSession,
selectedStoredSessionIdRef,
signatureRef,
updateSessionState
}: ActiveTranscriptRefreshDeps): Promise<void> {
const storedSessionId = selectedStoredSessionIdRef.current
const runtimeSessionId = activeSessionIdRef.current
if (!storedSessionId || !runtimeSessionId || busyRef.current) {
return
}
const stored = resolveSession(storedSessionId)
if (!stored) {
return
}
const requestId = requestSequenceRef.current + 1
requestSequenceRef.current = requestId
try {
const profileScope: ProfileScope = stored.ownerRoute
? {
connectionId: stored.ownerRoute.connectionId,
profile: stored.ownerRoute.targetProfile ?? stored.ownerRoute.profile
}
: stored.profile
const latest = await getLatestSessionMessages(storedSessionId, profileScope)
if (
requestId !== requestSequenceRef.current ||
busyRef.current ||
selectedStoredSessionIdRef.current !== storedSessionId ||
activeSessionIdRef.current !== runtimeSessionId
) {
return
}
const signatureKey = stored.ownerRoute
? JSON.stringify([
stored.ownerRoute.connectionId,
stored.ownerRoute.profile,
stored.ownerRoute.targetProfile ?? '',
stored.ownerRoute.mode ?? '',
storedSessionId
])
: `${stored.profile ?? 'default'}:${storedSessionId}`
const signature = sessionMessagesSignature(latest.messages)
if (signatureRef.current.get(signatureKey) === signature) {
return
}
signatureRef.current.set(signatureKey, signature)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
// The refresh re-reads only the newest tail page; graft it onto any
// older pages "Show earlier" already backfilled instead of clobbering
// them (see transcript-backfill).
messages: preserveLocalAssistantErrors(graftRefreshedTailOntoBackfill(messages, state.messages), state.messages)
}),
storedSessionId
)
} catch {
// Non-fatal: the next change event or manual resume can hydrate the view.
}
}
// Cron sessions are written by a background scheduler tick, messaging turns by
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
// desktop websocket directly. Backends with the change watcher broadcast
// `cron.changed` / `sessions.changed` when those on-disk writes land, so the
// timers below become slow safety-net backstops; against an older backend
// (no `change_events` on gateway.ready) they stay at the legacy cadence.
const CRON_POLL_INTERVAL_MS = 30_000
const CRON_BACKSTOP_INTERVAL_MS = 5 * 60_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
const ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS = 30_000
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
// stored session id while its turn keeps running; until the next snapshot the
// sidebar row points at the new id while the renderer still knows the old one.
// A 15s cadence made that healthy transition look finished long enough to be
// alarming (and clicking the row appeared to "fix" it by touching the live
// session). This snapshot is small and already polled at 1.5s by the TUI.
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
// With change events the snapshot re-pulls on every sessions.changed tick, so
// the interval only covers the degraded-socket edge the stream can't replay
// (see rehydrateLiveSessionStatuses) — 30s is plenty for that.
const LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS = 30_000
// Coalesce tick-driven sidebar list refreshes: sessions.changed fires (floored
// to 2s server-side) on every state.db write during a streaming turn, and the
// full list refresh is heavier than the active_list snapshot. Trailing-edge
// scheduled, so the burst's last write always lands.
const SESSIONS_LIST_TICK_GAP_MS = 10_000
// A typing burst keeps the composer's contentEditable input handling on the
// same renderer main thread as the list refresh above (#95033): with a large
// session store, one refresh pass can block keystroke echo long enough that
// input visibly stalls. While the keyboard is warm — any keydown in this
// renderer window, not just the composer — hold that pass and land it once
// shortly after the last keypress. Sidebar staleness during a burst is
// accepted; the lighter polls (active_list snapshot, cron, transcript
// backstops) keep their cadence because they carry liveness, not the heavy
// list reconciliation.
const TYPING_BURST_QUIET_MS = 1_500
interface LiveSessionStatusItem {
id?: string
last_active?: number
session_key?: string
status?: 'idle' | 'starting' | 'waiting' | 'working'
}
interface LiveSessionStatusResponse {
sessions?: LiveSessionStatusItem[]
}
// Runtime ids this poll has seen live, per gateway profile. A profile only
// ever reaps what its OWN snapshot previously reported: background profiles are
// served by different gateways and never appear in this profile's active_list,
// so an unscoped reap would dark out every other profile's running rows.
const liveRuntimeIdsByProfile = new Map<string, Set<string>>()
// Renderer-wide keyboard warmth, tracked at module scope like the live-runtime
// bookkeeping above: any keydown anywhere in the window marks activity, and a
// burst stays warm for TYPING_BURST_QUIET_MS after the last key. IME
// composition still emits keydown (keyCode 229), so one listener covers both.
let lastRendererInputAt = 0
/** Record renderer-wide keyboard activity (wired to a capture-phase window
* keydown listener by useBackgroundSync). */
export function noteRendererKeyboardActivity(nowMs = Date.now()): void {
lastRendererInputAt = nowMs
}
/** True while a typing burst is still warm enough to hold the heavy list
* refresh (see TYPING_BURST_QUIET_MS). */
export function isTypingBurstActive(nowMs = Date.now()): boolean {
return nowMs - lastRendererInputAt < TYPING_BURST_QUIET_MS
}
function remainingTypingQuietMs(nowMs: number): number {
return Math.max(0, TYPING_BURST_QUIET_MS - (nowMs - lastRendererInputAt))
}
/** Forget keyboard history — test isolation only (mirrors
* resetLiveRuntimeTracking). */
export function resetTypingActivityTracking(): void {
lastRendererInputAt = 0
}
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
* normally own these states, but events emitted while Desktop was disconnected
* cannot be replayed. `session.active_list` is the authoritative in-memory
* snapshot and does not resume, focus, or otherwise mutate a chat.
*
* The snapshot is authoritative about ABSENCE too. A turn that ends while the
* websocket is degraded — a remote gateway over a flaky link, a reconnect, a
* profile swap — drops out of `_sessions` without Desktop ever seeing the
* `running: false` edge, so the row keeps spinning and the busy→idle transition
* that paints the green "your turn" dot never fires. Reaping runtimes that
* vanish between polls restores both. */
export function rehydrateLiveSessionStatuses(
response: LiveSessionStatusResponse,
nowMs = Date.now(),
profileKey = 'default'
): void {
const seen = new Set<string>()
for (const session of response.sessions ?? []) {
const runtimeSessionId = session.id?.trim()
const storedSessionId = session.session_key?.trim()
const needsInput = session.status === 'waiting'
const working = session.status === 'working' || needsInput
if (!runtimeSessionId || !storedSessionId) {
continue
}
seen.add(runtimeSessionId)
const existing = $sessionStates.get()[runtimeSessionId]
// A turn we just submitted is not yet running as far as the backend is
// concerned, so the snapshot honestly reports it idle — but the local
// stream is already waiting on its first token, and it is the newer
// information. The stream path refuses to clear busy in exactly this window
// (`awaitingResponse && !sawAssistantPayload`); without the same refusal
// here a poll lands between submit and first token and darkens the row.
const busy = working || Boolean(existing?.awaitingResponse && !existing.sawAssistantPayload)
// Avoid re-arming the watchdog on every poll. Publish only when the
// authoritative live snapshot differs from the renderer mirror; normal
// gateway events continue to own subsequent transitions.
if (
!existing ||
existing.storedSessionId !== storedSessionId ||
existing.busy !== busy ||
existing.needsInput !== needsInput
) {
publishSessionState(runtimeSessionId, {
...(existing ?? createClientSessionState(storedSessionId)),
busy,
needsInput,
storedSessionId
})
}
if (!working) {
setSessionStalled(storedSessionId, false)
continue
}
const lastActiveMs = Number(session.last_active) * 1000
const isQuiet =
session.status === 'working' &&
Number.isFinite(lastActiveMs) &&
lastActiveMs > 0 &&
nowMs - lastActiveMs >= SESSION_WATCHDOG_TIMEOUT_MS
setSessionStalled(storedSessionId, isQuiet)
}
// A runtime this profile's snapshot reported live LAST poll but not this one
// has ended: the gateway reaps a session out of `_sessions` when its turn
// completes and its transport goes away. Settle it through the normal publish
// path so the busy→idle transition fires — that edge is what clears the
// spinner AND marks the row unread ("your turn"). Only ids this profile
// previously saw are eligible, so another profile's live rows are untouched.
const previouslyLive = liveRuntimeIdsByProfile.get(profileKey)
if (previouslyLive) {
for (const runtimeSessionId of previouslyLive) {
if (seen.has(runtimeSessionId)) {
continue
}
const existing = $sessionStates.get()[runtimeSessionId]
if (existing?.busy || existing?.needsInput || existing?.awaitingResponse) {
publishSessionState(runtimeSessionId, {
...existing,
awaitingResponse: false,
busy: false,
needsInput: false,
streamId: null,
turnStartedAt: null,
turnLive: false,
// The turn ended without its completion events reaching us — a lost
// `tool.complete` would otherwise leave a spinning tool row in an
// idle session. Seal open tool parts the same way the settle path
// does, so the transcript matches the state.
messages: sealOpenToolParts(existing.messages)
})
}
}
}
liveRuntimeIdsByProfile.set(profileKey, seen)
}
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
* drops the session states these ids point at, so a carried-over set would
* only reap runtimes that no longer exist. */
export function resetLiveRuntimeTracking(): void {
liveRuntimeIdsByProfile.clear()
}
interface BackgroundSyncParams {
activeConnectionId: null | string
activeGatewayProfile: string
activeIsMessaging: boolean
activeSessionId: null | string
activeStoredSessionId: null | string
freshDraftReady: boolean
gatewayState: string
refreshActiveTranscript: () => Promise<unknown> | unknown
refreshCronJobs: () => Promise<unknown> | unknown
refreshCurrentModel: (force?: boolean) => Promise<unknown> | unknown
refreshHermesConfig: () => Promise<unknown> | unknown
refreshMessagingSessions: () => Promise<unknown> | unknown
refreshSessions: () => Promise<unknown> | unknown
requestGateway: GatewayRequester
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
* re-focus. On battery the cadence stretches (see store/power) — these are
* safety-net refreshes, not the live path, so they're the right thing to slow
* when the machine is spending its charge. Returns nothing — meant to live
* inside an effect. */
export function windowIsActivelyViewed({
focused,
visibilityState
}: {
focused: boolean
visibilityState: DocumentVisibilityState
}): boolean {
return visibilityState === 'visible' && focused
}
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
// On macOS an unfocused or app-hidden BrowserWindow commonly remains
// `visibilityState === "visible"`. Visibility alone therefore kept every
// safety-net gateway poll alive while the user was in another app. These
// are stale-data backstops, not the live event path, so pause them until
// the window is actually being viewed and catch up immediately on focus.
if (windowIsActivelyViewed({ focused: document.hasFocus(), visibilityState: document.visibilityState })) {
tick()
}
}
let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get()))
const unsubscribeBattery = $onBattery.listen(onBattery => {
window.clearInterval(intervalId)
intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery))
})
document.addEventListener('visibilitychange', run)
window.addEventListener('focus', run)
return () => {
unsubscribeBattery()
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
window.removeEventListener('focus', run)
}
}
/**
* Keeps app data live while the gateway is open: an on-connect reseed (model /
* profile / sessions + relative-cwd resolution), the cron / messaging /
* open-transcript visibility polls, and the fresh-draft model/config reseed.
* All the "the desktop websocket won't tell us, so poll" logic in one place.
*/
export function useBackgroundSync({
activeConnectionId,
activeGatewayProfile,
activeIsMessaging,
activeSessionId,
activeStoredSessionId,
freshDraftReady,
gatewayState,
refreshActiveTranscript,
refreshCronJobs,
refreshCurrentModel,
refreshHermesConfig,
refreshMessagingSessions,
refreshSessions,
requestGateway,
updateSessionState
}: BackgroundSyncParams): void {
const changeEventsAvailable = useStore($changeEventsAvailable)
const cronChangeTick = useStore($cronChangeTick)
const sessionsChangeTick = useStore($sessionsChangeTick)
const activeTranscriptBusy = useStore($busy)
const activeTranscriptRefreshPendingRef = useRef<string | null>(null)
// Tile reconcile state (#93942 slice 1): shared sequence guard + per-tile
// transcript signatures, so no-change ticks and closed tiles cost nothing.
const tileRequestSequenceRef = useRef(0)
const tileSignatureRef = useRef(new Map<string, string>())
// Tile reconciliation reads each runtime's live state directly from
// $sessionStates; the primary chat's $busy atom has no authority over tiles.
const requestActiveTranscriptRefresh = useCallback(
(preservePending: boolean) => {
if (!activeStoredSessionId || !activeSessionId) {
return
}
const storedSessionId = activeStoredSessionId
const runtimeSessionId = activeSessionId
const sessionKey = `${storedSessionId}:${runtimeSessionId}`
if (preservePending) {
activeTranscriptRefreshPendingRef.current = sessionKey
}
if ($busy.get()) {
return
}
if (preservePending && activeTranscriptRefreshPendingRef.current === sessionKey) {
activeTranscriptRefreshPendingRef.current = null
}
let sawBusyDuringRead = false
const unsubscribeBusy = $busy.listen(busy => {
sawBusyDuringRead ||= busy
})
void Promise.resolve(refreshActiveTranscript()).finally(() => {
unsubscribeBusy()
// If streaming began while the read was in flight, reconciliation was
// discarded and the external event still needs one idle retry.
if (
preservePending &&
(sawBusyDuringRead || $busy.get()) &&
$activeSessionId.get() === runtimeSessionId &&
$selectedStoredSessionId.get() === storedSessionId
) {
activeTranscriptRefreshPendingRef.current = sessionKey
}
})
},
[activeSessionId, activeStoredSessionId, refreshActiveTranscript]
)
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions()
// A RELATIVE workspace cwd (config `terminal.cwd: .`) renders as "." in the
// file tree header — resolve it to the backend's absolute path once.
// Session runtime info still overrides later, and never while a session is
// active.
const cwd = $currentCwd.get().trim()
if (!$activeSessionId.get() && cwd && !/^(\/|[A-Za-z]:[\\/])/.test(cwd)) {
void requestGateway<{ cwd?: string }>('config.get', { key: 'project', cwd })
.then(info => {
if (info.cwd && !$activeSessionId.get()) {
setCurrentCwd(info.cwd)
}
})
.catch(() => undefined)
}
}, [activeConnectionId, activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
// Reconnect backstop (#94779): turns that finished while the socket was
// down never replay their sessions.changed tick, so the open transcript
// stayed stale until the user reopened it. Pull one signature-gated tail on
// every (re)connect — a no-change read costs nothing. Keyed on the
// connection, not the session, so a plain session switch adds no read;
// messaging transcripts already refresh on open in their own effect below.
useEffect(() => {
if (gatewayState === 'open' && !activeIsMessaging && activeSessionId && activeStoredSessionId) {
requestActiveTranscriptRefresh(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- connect-scoped: session deps would fire on every switch
}, [activeConnectionId, activeGatewayProfile, gatewayState])
// A reconnect loses renderer-only working/attention atoms while the backend
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
// registry immediately, then re-pull on every sessions.changed broadcast; a
// slow visible poll remains as the backstop for the degraded-socket edge the
// stream cannot replay (legacy cadence against older backends).
useEffect(() => {
if (gatewayState !== 'open') {
return
}
let cancelled = false
let inFlight = false
const refreshLiveStatuses = async () => {
if (inFlight) {
return
}
inFlight = true
try {
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
if (!cancelled) {
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile)
}
} catch {
// Older gateways may not expose session.active_list. Live stream events
// still work as before; leave the current sidebar state untouched.
} finally {
inFlight = false
}
}
const dispose = visiblePoll(
changeEventsAvailable ? LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS : LIVE_SESSION_STATUS_POLL_INTERVAL_MS,
() => void refreshLiveStatuses()
)
void refreshLiveStatuses()
return () => {
cancelled = true
dispose()
}
// sessionsChangeTick: each sessions.changed broadcast re-seeds immediately
// via the effect re-run (already coalesced to 2s server-side).
}, [activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway, sessionsChangeTick])
// sessions.changed also means the *stored* list may have new rows (a cron
// run's session, an inbound messaging turn creating a thread). The full list
// refresh is heavier than the active_list snapshot, so trail it on a gap
// instead of firing per tick. Direct atom subscription: the throttle state
// lives in the effect closure, not in refs synced from renders.
useEffect(() => {
if (gatewayState !== 'open' || !changeEventsAvailable) {
return
}
let lastRunAt = 0
let timer: null | number = null
let typingDeferTimer: null | number = null
const run = () => {
lastRunAt = Date.now()
void refreshSessions()
void refreshMessagingSessions()
// The project tree is a grouping of the same stored rows, so a session
// created/deleted/renamed/re-homed outside this window goes stale in the
// Projects sidebar without this (#100354). refreshProjectTree() keeps the
// cached tree on failure, so a not-yet-ready backend costs nothing.
void refreshProjectTree()
requestActiveTranscriptRefresh(true)
// Bot canonical chats live in workspace tiles, never in the main-pane
// selection — without this they never see background deliveries
// (#93942 scenario A). Signature-gated per tile, so no-change ticks
// cost nothing.
void reconcileTileTranscripts({
requestSequenceRef: tileRequestSequenceRef,
signatureRef: tileSignatureRef,
updateSessionState
})
}
// Hold the coalesced pass while a typing burst is warm (#95033) so the
// heavy list work never lands under keystrokes. One timer services every
// caller: ticks that arrive mid-deferral find it already armed and return.
// Fire time is the remaining quiet window, not a poll — a later key
// extends lastRendererInputAt, and the firing callback re-arms if still
// warm. There is no starvation cap: a continuous burst keeps holding.
const runWhenKeyboardQuiet = () => {
const now = Date.now()
if (!isTypingBurstActive(now)) {
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
typingDeferTimer = null
}
run()
return
}
if (typingDeferTimer === null) {
typingDeferTimer = window.setTimeout(() => {
typingDeferTimer = null
runWhenKeyboardQuiet()
}, remainingTypingQuietMs(now))
}
}
const unsubscribe = $sessionsChangeTick.listen(() => {
const since = Date.now() - lastRunAt
if (since >= SESSIONS_LIST_TICK_GAP_MS) {
runWhenKeyboardQuiet()
} else if (typingDeferTimer === null && timer === null) {
// Within the gap a pass is already scheduled — trailing timer or a
// typing deferral. Arming another one here would stack extra passes.
timer = window.setTimeout(() => {
timer = null
runWhenKeyboardQuiet()
}, SESSIONS_LIST_TICK_GAP_MS - since)
}
})
return () => {
unsubscribe()
if (timer !== null) {
window.clearTimeout(timer)
}
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
}
}
}, [
changeEventsAvailable,
gatewayState,
refreshMessagingSessions,
refreshSessions,
requestActiveTranscriptRefresh,
updateSessionState
])
// Keyboard warmth for the deferral above: capture phase on window. Any
// keydown in this renderer (composer, modal, settings) counts — conservative
// on purpose. Pure timestamp write, no React state.
useEffect(() => {
const markInput = (): void => noteRendererKeyboardActivity()
window.addEventListener('keydown', markInput, true)
return () => {
window.removeEventListener('keydown', markInput, true)
}
}, [])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background). cron.changed (jobs.json moved: CRUD or a scheduler tick's
// bookkeeping) drives the refresh; the visible poll is the backstop.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
if (cronChangeTick > 0) {
void refreshCronJobs()
}
return visiblePoll(
changeEventsAvailable ? CRON_BACKSTOP_INTERVAL_MS : CRON_POLL_INTERVAL_MS,
() => void refreshCronJobs()
)
}, [changeEventsAvailable, cronChangeTick, gatewayState, refreshCronJobs])
// A busy transition only consumes a pending sessions.changed refresh. It
// never creates one, so an ordinary local turn going busy -> idle does not
// add a REST read. The event itself is coalesced by the list throttle above.
useEffect(() => {
if (
gatewayState !== 'open' ||
activeTranscriptBusy ||
!activeSessionId ||
!activeStoredSessionId ||
activeTranscriptRefreshPendingRef.current !== `${activeStoredSessionId}:${activeSessionId}`
) {
return
}
requestActiveTranscriptRefresh(true)
}, [activeSessionId, activeStoredSessionId, activeTranscriptBusy, gatewayState, requestActiveTranscriptRefresh])
// Preserve the pre-existing messaging behavior: refresh once when a
// messaging transcript opens, then keep its visibility backstop. Desktop
// sessions never enter this effect and therefore gain no periodic timer.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging || !activeSessionId || !activeStoredSessionId) {
return
}
const runScheduledRefresh = () => requestActiveTranscriptRefresh(false)
runScheduledRefresh()
return visiblePoll(
changeEventsAvailable ? ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS : ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
runScheduledRefresh
)
}, [
activeIsMessaging,
activeSessionId,
activeStoredSessionId,
changeEventsAvailable,
gatewayState,
requestActiveTranscriptRefresh
])
// Messaging session lists against an older backend: no sessions.changed, so
// keep the legacy visible poll. (Event-capable backends fold this into the
// trailing sessions.changed refresh above.)
useEffect(() => {
if (gatewayState !== 'open' || changeEventsAvailable) {
return
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions])
// A fresh new-session draft (gateway open, no active session) re-pulls the
// model + config so the composer pill reflects the profile default.
useEffect(() => {
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
void refreshCurrentModel()
void refreshHermesConfig()
}
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
}
@@ -0,0 +1,570 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { _resetLegacyDiscardForTests } from '@/store/session'
import type * as WindowsStore from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
import { makeSessionInfo } from '../../../test/session-info'
import { useDesktopIntegrations } from './use-desktop-integrations'
// Mutable HUD-window flag so the restore tests can flip the window kind the
// hook believes it runs in. Default false keeps the pre-existing restore
// coverage exercising the real main-window path.
const { hudWindowMock } = vi.hoisted(() => ({ hudWindowMock: vi.fn(() => false) }))
vi.mock('@/store/mcp-deeplink-install', () => ({
requestMcpInstallFromDeepLink: vi.fn()
}))
vi.mock('@/store/windows', async importOriginal => {
const actual = await importOriginal<typeof WindowsStore>()
return {
...actual,
isHudWindow: () => hudWindowMock()
}
})
// Pure-jsdom localStorage (no nanostores persistence module needed — the
// production functions write directly to window.localStorage through the
// persistString/storedString helpers in @/lib/storage, which in jsdom resolves
// to the real localStorage global).
// We import the hook and drive it with explicit rx-stores/props to exercise the
// profile-ready gate, ownership validation, and legacy-key discard.
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const session = (over: Partial<SessionInfo> = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over })
describe('useDesktopIntegrations', () => {
let navigate: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
beforeEach(() => {
window.localStorage.clear()
_resetLegacyDiscardForTests()
vi.mocked(requestMcpInstallFromDeepLink).mockClear()
navigate = vi.fn()
// Every test starts as a main window; only the HUD describe flips this.
hudWindowMock.mockReturnValue(false)
// Stub the desktop bridge so the hook's useEffect callbacks don't try to
// reach real Electron IPC. The established desktop-test pattern assigns a
// plain object to window.hermesDesktop rather than using vi.spyOn.
desktopWindow.hermesDesktop = {
setPreviewShortcutActive: vi.fn(),
onOpenUpdatesRequested: vi.fn(),
onFocusSession: vi.fn(),
onNotificationAction: vi.fn(),
onNotificationActivate: vi.fn(),
onDeepLink: vi.fn(),
signalDeepLinkReady: vi.fn(),
onClosePreviewRequested: vi.fn(),
onOpenFolderRequested: vi.fn()
} as unknown as Window['hermesDesktop']
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
}
vi.restoreAllMocks()
})
function render({
activeProfile = 'default',
locationPathname = '/',
profileReady = false,
resumeExhaustedSessionId = null as string | null,
// null = config record still loading (the hook takes undefined; null dodges the destructuring default).
resumeLastSession = true as boolean | null,
routedSessionId = null as string | null,
sessions = [] as readonly SessionInfo[]
} = {}) {
return renderHook(
({
activeProfile,
locationPathname,
profileReady,
resumeExhaustedSessionId,
resumeLastSession,
routedSessionId,
sessions
}: {
activeProfile: string
locationPathname: string
profileReady: boolean
resumeExhaustedSessionId: string | null
resumeLastSession: boolean | null
routedSessionId: string | null
sessions: readonly SessionInfo[]
}) =>
useDesktopIntegrations({
activeProfile,
chatOpen: false,
hasPreview: false,
locationPathname,
navigate,
profileReady,
refreshSessions: vi.fn(),
resumeExhaustedSessionId,
resumeLastSession: resumeLastSession ?? undefined,
routedSessionId,
runtimeIdByStoredSessionId: { current: new Map() },
sessions
}),
{
initialProps: {
activeProfile,
locationPathname,
profileReady,
resumeExhaustedSessionId,
resumeLastSession,
routedSessionId,
sessions
}
}
)
}
describe('profile-ready gate', () => {
it('does NOT restore before profileReady is true', () => {
// Set remembered state, but profileReady=false.
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
render({ profileReady: false })
// no navigation should have occurred
expect(navigate).not.toHaveBeenCalled()
})
it('restores on profileReady when remembered route exists and owns the session', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
render({ profileReady: true, sessions })
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
it('restores remembered session id when no remembered route exists', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
render({ profileReady: true, sessions })
// sessionRoute('remembered-session') = '/remembered-session'
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
it('waits for sessions before validating a remembered session route', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
const result = render({ profileReady: true, sessions: [] })
expect(navigate).not.toHaveBeenCalled()
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/remembered-session')
result.rerender({
activeProfile: 'default',
locationPathname: '/',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions: [session({ id: 'remembered-session', profile: 'default' })]
})
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
})
describe('display.resume_last_session', () => {
it('stays on the fresh chat when the setting is off, and keeps remembering the open chat', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
const result = render({ profileReady: true, resumeLastSession: false, sessions })
expect(navigate).not.toHaveBeenCalled()
// The user opens another chat: it is still remembered for the next launch
// (and for notifications), so flipping the switch back on resumes it.
result.rerender({
activeProfile: 'default',
locationPathname: '/other-session',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: false,
routedSessionId: 'other-session',
sessions: [...sessions, session({ id: 'other-session', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
})
it('holds the restore until the config record answers, then restores when on', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
const sessions = [session({ id: 'remembered-session', profile: 'default' })]
const result = render({ profileReady: true, resumeLastSession: null, sessions })
expect(navigate).not.toHaveBeenCalled()
result.rerender({
activeProfile: 'default',
locationPathname: '/',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions
})
expect(navigate).toHaveBeenCalledWith('/remembered-session', { replace: true })
})
})
describe('ownership validation', () => {
it('refuses to restore a session route owned by another profile', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
// The route belongs to ai-engineer; active profile is default.
// No navigation should happen — wrong owner.
render({ activeProfile: 'default', profileReady: true, sessions })
expect(navigate).not.toHaveBeenCalled()
})
it('refuses to restore a session id owned by another profile', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'ai-session')
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
render({ activeProfile: 'default', profileReady: true, sessions })
// Both route and fallback session id are owned by another profile.
expect(navigate).not.toHaveBeenCalled()
})
it('clears stale remembered route owned by wrong profile', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.ai-engineer', '/ai-session')
const sessions = [session({ id: 'ai-session', profile: 'ai-engineer' })]
render({ activeProfile: 'ai-engineer', profileReady: true, sessions })
// The route and session match the active profile — should restore.
expect(navigate).toHaveBeenCalledWith('/ai-session', { replace: true })
})
})
describe('two profiles with distinct sessions', () => {
it('restores profile A session when profile A is active', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.coder', '/coder-session')
const sessions = [
session({ id: 'coder-session', profile: 'coder' }),
session({ id: 'ops-session', profile: 'ops' })
]
render({ activeProfile: 'coder', profileReady: true, sessions })
expect(navigate).toHaveBeenCalledWith('/coder-session', { replace: true })
})
it('does NOT bleed profile A session into profile B', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.coder', '/coder-session')
const sessions = [session({ id: 'coder-session', profile: 'coder' })]
// ops profile is active but has no own remembered route
render({
activeProfile: 'ops',
profileReady: true,
sessions
})
// No navigation — coder's remembered route doesn't belong to ops.
expect(navigate).not.toHaveBeenCalled()
})
})
describe('HUD window (win=hud)', () => {
beforeEach(() => {
hudWindowMock.mockReturnValue(true)
})
it('does NOT restore remembered navigation on a blank new-chat route', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/remembered-session')
render({ profileReady: true, sessions: [session({ id: 'remembered-session', profile: 'default' })] })
// The HUD is a fresh full renderer booting at the default route, but its
// destination was chosen explicitly by hudTargetSessionId() at open time
// — remembered-navigation restore must not hijack it to the last session.
expect(navigate).not.toHaveBeenCalled()
})
it('does NOT write remembered navigation while showing a session', () => {
render({
profileReady: true,
routedSessionId: 'live',
sessions: [session({ id: 'live', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
it('does not restore the remembered session id either', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'remembered-session')
render({ profileReady: true, sessions: [session({ id: 'remembered-session', profile: 'default' })] })
expect(navigate).not.toHaveBeenCalled()
})
})
describe('legacy key behavior', () => {
it('discards legacy global keys on read and does NOT restore from them', () => {
// Simulate a pre-per-profile install.
window.localStorage.setItem('hermes.desktop.lastSessionId', 'legacy-session')
window.localStorage.setItem('hermes.desktop.lastRoute', '/session/legacy-session')
// Profile contexts without matching sessions.
const sessions = [session({ id: 'legacy-session', profile: 'default' })]
render({ profileReady: true, sessions })
// Legacy keys must be discarded.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId')).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.lastRoute')).toBeNull()
// And no navigation should happen (the per-profile keys were empty).
expect(navigate).not.toHaveBeenCalled()
})
})
describe('stale-result suppression during profile switch', () => {
it('remembers route for the new profile after switch, not the old one', () => {
const sessions = [
session({ id: 'coder-session', profile: 'coder' }),
session({ id: 'ops-session', profile: 'ops' })
]
// Render with coder active and navigate to a session.
const { rerender } = render({
activeProfile: 'coder',
locationPathname: '/coder-session',
profileReady: true,
routedSessionId: 'coder-session',
sessions
})
// The coder session should be persisted under coder's key.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.coder')).toBe('coder-session')
// Now switch to ops.
rerender({
activeProfile: 'ops',
locationPathname: '/ops-session',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: 'ops-session',
sessions
})
// The ops session should now be persisted under ops's key.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.ops')).toBe('ops-session')
// Coder's remembered session should still be there.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.coder')).toBe('coder-session')
})
it('does NOT overwrite remembered state when session ownership fails validation', () => {
// Simulate an async restore result arriving for a route that doesn't
// own the active profile.
const sessions = [session({ id: 'coder-session', profile: 'coder' })]
// Active profile is ops, but the routed session belongs to coder.
render({
activeProfile: 'ops',
locationPathname: '/',
profileReady: true,
routedSessionId: 'coder-session', // wrong profile!
sessions
})
// No session should be remembered for the active profile.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.ops')).toBeNull()
})
})
describe('route-scoped restoration', () => {
it('restores a non-session route like /skills', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/skills')
const sessions = [session({ id: 'some-session', profile: 'default' })]
render({ profileReady: true, sessions })
// /skills is not a session route — no ownership validation needed.
expect(navigate).toHaveBeenCalledWith('/skills', { replace: true })
})
it('does NOT restore overlay routes (settings/command-center)', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/settings')
render({ profileReady: true, sessions: [] })
// Overlay routes should not be restored.
expect(navigate).not.toHaveBeenCalled()
})
it('does NOT persist overlay routes for next boot', () => {
const { rerender } = render({
activeProfile: 'default',
locationPathname: '/settings',
profileReady: true,
routedSessionId: null,
sessions: []
})
// Remembering effect fires on route change.
rerender({
activeProfile: 'default',
locationPathname: '/settings',
profileReady: true,
resumeExhaustedSessionId: null,
resumeLastSession: true,
routedSessionId: null,
sessions: []
})
// Overlay routes must NOT be persisted.
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
})
describe('exhausted session cleanup', () => {
it('clears remembered session id when the exhausted session matches', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'exhausted')
const sessions = [session({ id: 'exhausted', profile: 'default' })]
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBeNull()
})
it('clears remembered route when it carries the exhausted session', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/exhausted')
const sessions = [session({ id: 'exhausted', profile: 'default' })]
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions
})
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBeNull()
})
it('does NOT clear exhausted when profileReady is false', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'exhausted')
render({
profileReady: false,
resumeExhaustedSessionId: 'exhausted',
sessions: []
})
// profileReady=false gates the cleanup effect.
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('exhausted')
})
it('does NOT clear remembered state when exhausted id does not match', () => {
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'other-session')
render({
profileReady: true,
resumeExhaustedSessionId: 'exhausted',
sessions: [session({ id: 'other-session', profile: 'default' })]
})
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
})
})
describe('notification activate + plugin deep links', () => {
it('navigates when a plugin notification activate payload arrives', () => {
let activate: ((payload: { activate?: string }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onNotificationActivate: (cb: (payload: { activate?: string }) => void) => {
activate = cb
return () => undefined
}
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
activate?.({ activate: '/index-network/intent/1' })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('navigates hermes://index-network/intent/1 deep links through the same path vocabulary', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'index-network', name: 'intent/1', params: {} })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('routes hermes://mcp/install to the pending-install dialog, not navigation', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'mcp', name: 'install', params: { name: 'context7' } })
expect(requestMcpInstallFromDeepLink).toHaveBeenCalledWith({ name: 'context7' })
expect(navigate).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,363 @@
import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav'
import { openSession } from '@/app/open-session'
import { resolveDeepLinkAction } from '@/lib/deeplink-routes'
import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open-target'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
import {
clearPluginNotifyHandlers,
invokePluginNotifyAction,
invokePluginNotifyActivate,
respondToApprovalAction
} from '@/store/native-notifications'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { openFolderAsProject } from '@/store/projects'
import {
getRememberedRoute,
getRememberedSessionId,
sessionBelongsToProfile,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isBrowserWindow, isHudWindow, isSecondaryWindow } from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '../../routes'
type RememberedSession = Pick<SessionInfo, '_lineage_root_id' | 'id' | 'profile'>
interface DesktopIntegrationsParams {
activeProfile: string
chatOpen: boolean
hasPreview: boolean
locationPathname: string
navigate: (to: string, options?: { replace?: boolean }) => void
profileReady: boolean
refreshSessions: () => Promise<unknown> | unknown
/** `display.resume_last_session`; `undefined` while the config record is still loading. */
resumeLastSession: boolean | undefined
resumeExhaustedSessionId: null | string
routedSessionId: null | string
runtimeIdByStoredSessionId: { readonly current: Map<string, string> }
sessions: readonly RememberedSession[]
}
/**
* All the Electron-main / OS / cross-window integrations the shell listens for:
* update polling, the ⌘W close shortcut, deep links, native-notification
* navigation, preview-shortcut enablement, remembered-session restore, and
* cross-window session-list sync. Kept out of the wiring controller so the
* "talks to the desktop shell" surface reads as one unit.
*/
export function useDesktopIntegrations({
activeProfile,
locationPathname,
navigate,
profileReady,
refreshSessions,
resumeLastSession,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionId,
sessions
}: DesktopIntegrationsParams): void {
// Update polling — populates $desktopVersion/$updateStatus, which feed the
// statusbar version pill and the update toasts. Also honors the main
// process's "open updates" menu request.
useEffect(() => {
startUpdatePoller()
// Background MCP health: HTTP/SSE servers only (never spawns stdio),
// notifies on transitions into needs-auth/error with a Sign in action.
startMcpHealthChecker()
// The native "Check for Updates…" menu item lives in the app menu next to
// "About Hermes" — it is the OS-standard affordance for updating THIS app,
// so it always opens the client overlay. Inheriting the connection-mode
// default pointed a Mac at its remote Linux backend and left the app itself
// silently stale (#70266).
const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow('client'))
return () => {
unsubscribe?.()
stopUpdatePoller()
stopMcpHealthChecker()
}
}, [])
// The renderer OWNS ⌘W: on macOS the native menu accelerator would else
// close the window, so claim it unconditionally — the menu then routes ⌘W
// to us (close-preview-requested IPC) and we decide tab-vs-window.
useEffect(() => {
window.hermesDesktop?.setPreviewShortcutActive?.(true)
}, [])
const restoredRef = useRef(false)
// Wait until boot has adopted the primary profile, then restore that profile's
// navigation exactly once. The same effect owns subsequent writes so the
// initial `/` cannot overwrite remembered history before it is read.
// This ref is a one-time lifecycle latch, not a mirror of reactive atom state.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!profileReady || isHudWindow() || isBrowserWindow()) {
return
}
if (!restoredRef.current) {
// Only cold-start navigation at the default route is replaceable; a deep
// link or hidden-then-shown window keeps its explicit destination.
if (locationPathname === NEW_CHAT_ROUTE) {
// display.resume_last_session (#60812): hold the latch until the config
// record answers, then either restore below or stay on the fresh chat.
// Remembered ids keep being written either way, so flipping the switch
// back on resumes from the very next launch.
if (resumeLastSession === undefined) {
return
}
if (!resumeLastSession) {
restoredRef.current = true
return
}
const route = getRememberedRoute(activeProfile)
const routeSession = route ? routeSessionId(route) : null
const last = getRememberedSessionId(activeProfile)
const restorableNonSessionRoute =
!!route && route !== NEW_CHAT_ROUTE && !routeSession && !isOverlayView(appViewForPath(route))
// Boot adoption can publish renderer.ready before its async session
// refresh completes. Keep the restore latch open until ownership can be
// decided; treating an unloaded list as authoritative would erase valid
// remembered navigation permanently.
if (sessions.length === 0 && !restorableNonSessionRoute && (routeSession || last)) {
return
}
restoredRef.current = true
if (
route &&
route !== NEW_CHAT_ROUTE &&
!isOverlayView(appViewForPath(route)) &&
(!routeSession || sessionBelongsToProfile(sessions, routeSession, activeProfile))
) {
navigate(route, { replace: true })
return
}
// A remembered route carried a session id we can no longer validate —
// clear the stale entry so the next cold start won't re-try it.
if (routeSession) {
setRememberedRoute(null, activeProfile)
}
if (last && sessionBelongsToProfile(sessions, last, activeProfile)) {
navigate(sessionRoute(last), { replace: true })
return
}
if (last) {
setRememberedSessionId(null, activeProfile)
}
} else {
restoredRef.current = true
}
}
// Remember the open chat (session id for notifications/resume) AND the last
// non-overlay route (a page like /skills, or a session route) per profile.
// Session-shaped routes require an explicit matching owner; unresolved and
// wrong-profile rows must not replace known-safe navigation.
if (routedSessionId && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) {
setRememberedSessionId(routedSessionId, activeProfile)
setRememberedRoute(locationPathname, activeProfile)
} else if (!routedSessionId && !isOverlayView(appViewForPath(locationPathname))) {
setRememberedRoute(locationPathname, activeProfile)
}
}, [activeProfile, locationPathname, navigate, profileReady, resumeLastSession, routedSessionId, sessions])
useEffect(() => {
if (!profileReady || !resumeExhaustedSessionId) {
return
}
if (getRememberedSessionId(activeProfile) === resumeExhaustedSessionId) {
setRememberedSessionId(null, activeProfile)
}
if (routeSessionId(getRememberedRoute(activeProfile) ?? '') === resumeExhaustedSessionId) {
setRememberedRoute(null, activeProfile)
}
}, [activeProfile, profileReady, resumeExhaustedSessionId])
// Native-notification click -> jump to the session WHERE IT ALREADY IS (open
// tile / main), else beside what's loaded rather than over it — the click
// came from outside the app and shouldn't cost the user the chat they left
// on screen. Runtime id is translated to the stored id the chat route is
// keyed by; action buttons resolve in place.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
openSession(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionId.current), navigate, 'stack')
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionId])
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// Plugin OS notification body/action → optional callback + navigate. Activation
// is user-driven (click), so this is offer-not-hijack. Paths share the
// hermes://index-network/intent/1 vocabulary with deep links.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationActivate?.(payload => {
if (!payload) {
return
}
if (payload.actionId) {
invokePluginNotifyAction(payload.notifyId, payload.actionId)
} else {
invokePluginNotifyActivate(payload.notifyId)
}
if (payload.activate) {
// Defense-in-depth: re-resolve at the IPC boundary rather than trusting
// the pre-IPC validation — any future hermesDesktop.notify caller gets
// funneled through the same resolver.
const path = resolveHermesOpenPath(payload.activate)
if (path) {
navigate(path)
}
}
clearPluginNotifyHandlers(payload.notifyId)
})
return () => unsubscribe?.()
}, [navigate])
// hermes:// deep links:
// - mcp/install?… → pending MCP install (explicit confirm, never auto-install)
// - plugin/install?… (and legacy plugin-agent/plugin-desktop) → plugin install
// modal awaiting explicit confirmation. Never auto-installs.
// - blueprint/<name>?… → reviewable /blueprint command in the composer
// - <plugin>/<path>?… → in-app navigate (e.g. index-network/intent/1)
// - open/<path>?… → in-app navigate (generic)
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload?.kind) {
return
}
if (payload.kind === 'mcp' && payload.name === 'install') {
requestMcpInstallFromDeepLink(payload.params || {})
return
}
const action = resolveDeepLinkAction(payload)
if (action.type === 'composer-blueprint') {
const slots = Object.entries(action.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${action.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
return
}
if (action.type === 'plugin-install') {
openPluginInstallRequest({
repo: action.repo,
enable: action.enable,
force: action.force,
legacyHint: action.legacyHint
})
return
}
// Not a core action — treat as a plugin-scoped or open/ navigation deep
// link (hermes://index-network/intent/1, hermes://open/…). The resolver
// rejects reserved kinds and unsafe paths.
const path = pathFromHermesDeepLink(payload.kind, payload.name || '', payload.params || {})
if (path) {
navigate(path)
}
})
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [navigate])
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
// closeable, fall back to closing the window (so ⌘W still works as the
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
() => void closeActiveTab(id => navigate(sessionRoute(id)))
)
return () => unsubscribe?.()
}, [navigate])
// Native browser gestures (⌘R, a mouse's back/forward buttons, a trackpad
// swipe) that landed on the app's own chrome rather than inside a page — main
// answers those against the focused guest and never asks. Only ⌘R has an
// app-level meaning to fall back to; an unfocused swipe is a no-op.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onPreviewNav?.(command => {
if (!commandFocusedPreview(command) && command === 'reload') {
window.location.reload()
}
})
return () => unsubscribe?.()
}, [])
// File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow() || isBrowserWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions())
}, [refreshSessions])
}
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isAuxiliaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
interface PetBridgeParams {
requestGateway: GatewayRequester
resumeSession: (sessionId: string) => Promise<unknown> | unknown
submitText: (text: string) => Promise<unknown> | unknown
}
/**
* Wires the popped-out pet overlay back into the app: submit a prompt, resize,
* and open the most-recent thread, plus mirroring "a session is awaiting the
* user" into the pet's pose. Handlers register ONCE through refs tracking the
* latest callbacks — re-registering on identity churn leaves a nulled-handler
* window that can drop a submit. Primary window only.
*/
export function usePetBridge({ requestGateway, resumeSession, submitText }: PetBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
const requestGatewayRef = useRef(requestGateway)
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isAuxiliaryWindow()) {
return
}
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Alt+wheel resize from the popped-out pet — persist through this window's
// gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is most-recent-first; the pet is global, so "most
// recent" is the right target.
setPetOverlayOpenAppHandler(() => {
const recent = $sessions.get()[0]
if (recent?.id) {
void resumeSessionRef.current(recent.id)
}
})
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
setPetOverlayScaleHandler(null)
}
}, [])
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
// awaitingInput flag so it shows the `waiting` pose.
useEffect(() => {
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
sync()
return $attentionSessionIds.listen(sync)
}, [])
}
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react'
import {
initQuickEntryBridge,
QUICK_TARGET_CURRENT,
QUICK_TARGET_NEW,
type QuickEntrySessionOption,
setQuickEntrySubmitHandler
} from '@/store/quick-entry'
import { $gatewayState, $sessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isAuxiliaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
startFreshSessionDraft: () => void
submitText: (text: string) => Promise<unknown> | unknown
}
// The picker is a capture aid, not a session browser — a handful of recent
// rows is the whole point.
const QUICK_ENTRY_SESSION_OPTIONS = 5
function sessionOptions(): QuickEntrySessionOption[] {
return $sessions
.get()
.filter(session => !session.archived)
.slice(0, QUICK_ENTRY_SESSION_OPTIONS)
.map(session => ({
id: session.id,
title: session.title?.trim() || session.preview?.trim() || session.id
}))
}
/**
* Wires the global-hotkey Quick Entry window back into the app, both ways:
*
* - **Inbound:** text captured there is routed by target and submitted through
* THIS window's normal prompt machinery — current chat rides `submitText`, a
* picked stored session rides the session-tile delegate (resume + submit,
* background, without touching the primary view — the same path tiled
* sessions use), and "new session" is a fresh draft + submit, exactly what
* clicking New Chat and typing does. One submit pipeline, no bespoke RPC.
* - **Outbound:** gateway connection state + the recent-session list are pushed
* to the quick window (via main, which caches the latest push), so its input
* disables with a reconnect hint whenever the backend is unreachable.
*
* Handlers register ONCE through refs tracking the latest callbacks —
* re-registering on identity churn leaves a nulled-handler window that can drop
* a submit (the same bug shape use-pet-bridge guards). Primary window only: a
* secondary session window must not also claim the global capture channel, or
* one keystroke would send N prompts.
*/
export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: QuickEntryBridgeParams): void {
const submitTextRef = useRef(submitText)
submitTextRef.current = submitText
const startFreshRef = useRef(startFreshSessionDraft)
startFreshRef.current = startFreshSessionDraft
useEffect(() => {
if (isAuxiliaryWindow()) {
return
}
setQuickEntrySubmitHandler(({ target, text }) => {
if (target === QUICK_TARGET_NEW) {
// Same as the user clicking New Chat and typing: fresh draft, then the
// normal submit creates the backend session.
startFreshRef.current()
void submitTextRef.current(text)
return
}
if (target !== QUICK_TARGET_CURRENT) {
// A picked stored session: resume + submit in the background through
// the session-tile delegate so the primary view stays where it is.
const delegate = sessionTileDelegate()
if (delegate) {
void delegate
.resumeTile(target)
.then(runtimeId => delegate.submitToSession(runtimeId, text))
// A dead/undeliverable target must not swallow the prompt.
.catch(() => void submitTextRef.current(text))
return
}
}
void submitTextRef.current(text)
})
const dispose = initQuickEntryBridge()
return () => {
setQuickEntrySubmitHandler(null)
dispose()
}
}, [])
// Push gateway truth into the quick window whenever it changes: connection
// state gates its input; the recent-session list feeds its target picker.
useEffect(() => {
if (isAuxiliaryWindow()) {
return
}
const api = window.hermesDesktop?.quickEntry
if (!api?.pushState) {
return
}
const push = () => {
api.pushState({ connected: $gatewayState.get() === 'open', sessions: sessionOptions() })
}
push()
const offGateway = $gatewayState.listen(push)
const offSessions = $sessions.listen(push)
return () => {
offGateway()
offSessions()
}
}, [])
}
@@ -0,0 +1,378 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { setSessionOwnerHint, setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionTileDelegate } from './use-session-tile-delegate'
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))
vi.mock('@/store/gateway', async importActual => ({
...(await importActual<Record<string, unknown>>()),
requestGatewayForAgent: vi.fn(),
requestGatewayForProfile: vi.fn()
}))
const { getLatestSessionMessages } = await import('@/hermes')
const { requestGatewayForAgent, requestGatewayForProfile } = await import('@/store/gateway')
const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo
function renderTile(
requestGateway: ReturnType<typeof vi.fn>,
refs?: {
runtimeIdByStoredSessionIdRef?: { current: Map<string, string> }
sessionStateByRuntimeIdRef?: { current: Map<string, unknown> }
updateSessionState?: ReturnType<typeof vi.fn>
}
) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: (refs?.runtimeIdByStoredSessionIdRef ?? { current: new Map() }) as never,
sessionStateByRuntimeIdRef: (refs?.sessionStateByRuntimeIdRef ?? { current: new Map() }) as never,
updateSessionState: (refs?.updateSessionState ?? vi.fn()) as never
})
)
}
describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getLatestSessionMessages).mockClear()
})
afterEach(() => {
setSessions([])
})
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-1' } as never)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
expect(runtimeId).toBe('runtime-1')
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'ai-engineer',
'session.resume',
{
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer',
omit_messages: true
},
undefined,
undefined
)
expect(requestGateway).not.toHaveBeenCalled()
})
it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])
const requestGateway = vi.fn(async () => ({}) as never)
// #92961: a known owner is ALWAYS routed through the profile router —
// even 'default' — never dispatched on the ambient socket.
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-2' } as never)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-y')
expect(runtimeId).toBe('runtime-2')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'default',
'session.resume',
{
session_id: 'stored-y',
cols: 96,
profile: 'default',
omit_messages: true
},
undefined,
undefined
)
expect(requestGateway).not.toHaveBeenCalled()
})
it('carries a session row connection owner into a same-named tile resume', async () => {
setSessions([row({ connection_id: 'source-b', id: 'stored-shared', profile: 'default' })])
const ambientRequest = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-shared' } as never)
renderTile(ambientRequest)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-shared')
expect(runtimeId).toBe('runtime-shared')
expect(requestGatewayForAgent).toHaveBeenCalledWith('source-b', 'default', 'session.resume', {
session_id: 'stored-shared',
cols: 96,
omit_messages: true,
profile: 'default'
})
expect(ambientRequest).not.toHaveBeenCalled()
})
it('routes a Bot tile prefetch and resume through its exact connection owner', async () => {
const route = {
connectionId: 'barry',
mode: 'remote' as const,
profile: 'oxcoder',
targetProfile: 'backend-oxcoder'
}
setSessionOwnerHint('stored-remote', route)
vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-remote' } as never)
const ambientRequest = vi.fn(async () => ({}) as never)
renderTile(ambientRequest)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-remote')
expect(runtimeId).toBe('runtime-remote')
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-remote', {
connectionId: 'barry',
profile: 'backend-oxcoder'
})
expect(requestGatewayForAgent).toHaveBeenCalledWith('barry', 'oxcoder', 'session.resume', {
session_id: 'stored-remote',
cols: 96,
omit_messages: true,
profile: 'backend-oxcoder'
})
expect(ambientRequest).not.toHaveBeenCalled()
})
it('reuses a warm binding that still carries a transcript', async () => {
const stateA = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-a' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) }
const requestGateway = vi.fn(async () => ({}) as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a')
expect(runtimeId).toBe('runtime-a')
expect(requestGateway).not.toHaveBeenCalled()
expect(getLatestSessionMessages).not.toHaveBeenCalled()
})
it('merges persisted messages into a warm tile on explicit reopen (#96183)', async () => {
const stateA = {
busy: false,
messages: [{ id: 'm1', parts: [{ type: 'text', text: 'old' }], role: 'user' }],
storedSessionId: 'stored-a'
}
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) }
const updateSessionState = vi.fn((_id, updater) => updater(stateA))
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(getLatestSessionMessages).mockResolvedValueOnce({
messages: [
{ id: 'm1', content: 'old', role: 'user' },
{ id: 'm2', content: 'cron delivery', role: 'user' }
],
session_id: 'stored-a'
} as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a', { refreshTranscript: true })
expect(runtimeId).toBe('runtime-a')
expect(requestGateway).not.toHaveBeenCalled()
expect(getLatestSessionMessages).toHaveBeenCalled()
expect(updateSessionState).toHaveBeenCalled()
const updater = updateSessionState.mock.calls[0][1] as (state: typeof stateA) => {
messages: Array<{ parts?: Array<{ text?: string }> }>
}
const next = updater(stateA)
const texts = next.messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
expect(texts.some(text => text.includes('cron delivery'))).toBe(true)
})
it('falls through to a real resume when the warm binding has no transcript (post-wake empty tile)', async () => {
// Sleep/wake regression: a released/stale cached state (messages: []) must
// NOT satisfy the warm path — reusing it re-bound the tile to a dead
// runtime id and painted the pane permanently empty.
setSessions([row({ id: 'stored-b', profile: 'default' })])
const staleState = { busy: false, messages: [], storedSessionId: 'stored-b' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-b', 'runtime-dead']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', staleState]]) }
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-b')
expect(runtimeId).toBe('runtime-fresh')
expect(requestGatewayForProfile).toHaveBeenCalledWith(
'default',
'session.resume',
{
session_id: 'stored-b',
cols: 96,
profile: 'default',
omit_messages: true
},
undefined,
undefined
)
})
it('hydrates the tile model and provider from resume info', async () => {
setSessions([row({ id: 'stored-model', profile: 'default' })])
const updateSessionState = vi.fn()
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({
info: { fast: true, model: 'gpt-5', provider: 'openai', reasoning_effort: 'high', running: false },
session_id: 'runtime-model'
} as never)
renderTile(vi.fn(), { updateSessionState })
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-model')
expect(runtimeId).toBe('runtime-model')
expect(updateSessionState).toHaveBeenCalled()
const updater = updateSessionState.mock.calls[0][1] as (state: { messages: unknown[] }) => Record<string, unknown>
const next = updater({ messages: [] })
expect(next.model).toBe('gpt-5')
expect(next.provider).toBe('openai')
expect(next.reasoningEffort).toBe('high')
expect(next.fast).toBe(true)
})
it('invalidateRuntimeBindings clears the stored→runtime map so tiles re-resume after reconnect', async () => {
setSessions([row({ id: 'stored-c', profile: 'default' })])
const liveState = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-c' }
const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-c', 'runtime-dead']]) }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', liveState]]) }
const requestGateway = vi.fn(async () => ({}) as never)
vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never)
renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef })
// Gateway reconnect (what resetTileRuntimeBindings calls on wake):
sessionTileDelegate()!.invalidateRuntimeBindings!()
expect(runtimeIdByStoredSessionIdRef.current.size).toBe(0)
// The next resume goes cold instead of reusing the dead binding.
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-c')
expect(runtimeId).toBe('runtime-fresh')
})
})
describe('useSessionTileDelegate retireBusyClaim', () => {
it('retires a stale busy claim through the session-state write path (#93059)', () => {
const busyState = { awaitingResponse: true, busy: true, messages: [{ id: 'm1' }], storedSessionId: 'stored-d' }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', busyState]]) }
const updateSessionState = vi.fn()
renderTile(
vi.fn(async () => ({}) as never),
{ sessionStateByRuntimeIdRef, updateSessionState }
)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-dead')).toBe(true)
expect(updateSessionState).toHaveBeenCalledWith('runtime-dead', expect.any(Function))
// The updater is the downgrade: busy/awaiting off, everything else intact.
const updater = updateSessionState.mock.calls[0][1] as (state: typeof busyState) => typeof busyState
expect(updater(busyState)).toEqual({ ...busyState, awaitingResponse: false, busy: false })
})
it('reports a miss instead of minting a cache entry for a runtime it never held', () => {
// No phantoms: updateSessionState mints a state for any id it is handed,
// and prune never collects a transcript-less entry — so a miss must not
// reach the write path; the store retires its own mirror instead.
const idle = { awaitingResponse: false, busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-e' }
const sessionStateByRuntimeIdRef = { current: new Map([['runtime-idle', idle]]) }
const updateSessionState = vi.fn()
renderTile(
vi.fn(async () => ({}) as never),
{ sessionStateByRuntimeIdRef, updateSessionState }
)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-unknown')).toBe(false)
expect(sessionTileDelegate()!.retireBusyClaim!('runtime-idle')).toBe(false)
expect(updateSessionState).not.toHaveBeenCalled()
})
})
describe('useSessionTileDelegate interruptSession', () => {
beforeEach(() => {
setSessions([])
})
afterEach(async () => {
setSessions([])
const { clearSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils')
clearSessionRecentlyInterrupted()
})
it('marks the session recently interrupted so a quick tile edit/resend still interrupt-firsts (#83855)', async () => {
const { isSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils')
const requestGateway = vi.fn(async () => ({}) as never)
renderTile(requestGateway)
await sessionTileDelegate()!.interruptSession('runtime-tile-1')
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: 'runtime-tile-1' })
// Same 3s cooldown the primary chat's Stop sets: busy reads false while the
// gateway winds down, so the rewind path must still interrupt-first.
expect(isSessionRecentlyInterrupted('runtime-tile-1')).toBe(true)
})
})
@@ -0,0 +1,379 @@
import { useEffect } from 'react'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import {
fetchStoredTranscriptAcrossBackends,
getLatestSessionMessages,
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
} from '@/hermes'
import { translateNow } from '@/i18n/runtime'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import { notify } from '@/store/notifications'
import {
isReadOnlyRuntimeId,
readOnlyRuntimeIdFor,
resumeWithStoredTranscriptFallback
} from '@/store/read-only-transcript'
import { knownSessionOwner, ownerLookupSessionRows } from '@/store/session'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router'
import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { singleFlightSessionResume } from '../../session/hooks/use-prompt-actions/single-flight-resume'
import { markSessionRecentlyInterrupted, withSessionNotFoundResume } from '../../session/hooks/use-prompt-actions/utils'
import {
chatMessageArraysEquivalent,
reconcileResumeMessages,
resolveSessionOwner
} from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
type SessionStateCache = ReturnType<typeof useSessionStateCache>
function mergeTileTranscript(
previous: ChatMessage[],
prefetchMessages: SessionResumeResponse['messages'] | undefined
): ChatMessage[] {
const prefetched = toChatMessages(prefetchMessages ?? [])
if (!prefetched.length) {
return previous
}
const persisted = graftRefreshedTailOntoBackfill(prefetched, previous)
return reconcileResumeMessages(persisted, previous)
}
interface SessionTileDelegateParams {
archiveSession: (storedSessionId: string) => Promise<unknown>
branchStoredSession: (storedSessionId: string) => Promise<unknown>
executeSlashCommand: ReturnType<typeof usePromptActions>['executeSlashCommand']
removeSession: (storedSessionId: string) => Promise<unknown>
requestGateway: GatewayRequester
runtimeIdByStoredSessionIdRef: SessionStateCache['runtimeIdByStoredSessionIdRef']
sessionStateByRuntimeIdRef: SessionStateCache['sessionStateByRuntimeIdRef']
updateSessionState: SessionStateCache['updateSessionState']
}
/**
* Publishes the session-tile delegate: resume / submit / interrupt / slash for
* tiled sessions WITHOUT touching the primary view ($activeSessionId /
* $messages stay the main thread's). Resume reuses a live runtime binding when
* one exists (incl. the main thread's own session); a cold tile binds +
* hydrates the cache, which publishSessionState mirrors to the tile.
*/
export function useSessionTileDelegate({
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
}: SessionTileDelegateParams): void {
useEffect(() => {
// A tile's runtime binding can die the same way the foreground's does
// (sleep/wake, backend restart). The cache maps stored -> runtime, so walk
// it backwards to find the durable id this runtime belongs to.
const storedSessionIdForRuntime = (runtimeId: string): null | string => {
const cached = sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId
if (cached) {
return cached
}
for (const [storedId, mapped] of runtimeIdByStoredSessionIdRef.current) {
if (mapped === runtimeId) {
return storedId
}
}
return null
}
// Repoint the stored -> runtime mapping at the recovered id so subsequent
// tile actions use the live binding instead of re-recovering every call.
const rebindTileRuntime = (deadRuntimeId: string) => (recoveredId: string) => {
const storedId = storedSessionIdForRuntime(deadRuntimeId)
if (storedId) {
runtimeIdByStoredSessionIdRef.current.set(storedId, recoveredId)
}
}
// Same ladder as the window's session-RPC dispatcher: tile route → the
// row's owner (exact when connection-tagged, else the hint / profile) →
// the async cross-profile probe (exact when the resolved row is tagged).
const ownerForStoredSession = async (storedSessionId: string): Promise<SessionOwnerScope> => {
const owner =
sessionTileOwnerRoute(storedSessionId) ??
knownSessionOwner(ownerLookupSessionRows(), storedSessionId) ??
(await resolveSessionOwner(storedSessionId))
return owner
}
const requestForStoredSession = async <T>(
storedSessionId: string,
method: string,
params: Record<string, unknown>,
timeoutMs?: number
): Promise<T> => {
const owner = await ownerForStoredSession(storedSessionId)
return requestForSessionProfile<T>(owner, requestGateway, method, params, timeoutMs)
}
setSessionTileDelegate({
archiveSession: async storedSessionId => {
await archiveSession(storedSessionId)
},
branchSession: async storedSessionId => {
await branchStoredSession(storedSessionId)
},
deleteSession: async storedSessionId => {
await removeSession(storedSessionId)
},
executeSlash: async (rawCommand, sessionId) => {
await executeSlashCommand(rawCommand, { sessionId })
},
// Gateway reconnect (sleep/wake, backend respawn): every stored→runtime
// binding recorded pre-reconnect points at a runtime id the respawned
// backend no longer knows. Drop the map so resumeTile's warm path can't
// re-bind a tile to a dead runtime; live bindings re-record from
// post-reconnect events and fresh resumes.
invalidateRuntimeBindings: preserveStoredSessionIds => {
for (const storedSessionId of runtimeIdByStoredSessionIdRef.current.keys()) {
if (!preserveStoredSessionIds?.has(storedSessionId)) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
}
}
},
// Reconnect reconcile (#93059): retire an orphaned runtime's busy claim
// through updateSessionState so the cache, focused view, busyRef and
// tile mirrors settle together. A runtime this cache never held reports
// false instead of minting an entry; the store downgrades its mirror.
retireBusyClaim: runtimeId => {
const cached = sessionStateByRuntimeIdRef.current.get(runtimeId)
if (!cached || (!cached.busy && !cached.awaitingResponse)) {
return false
}
updateSessionState(runtimeId, state => ({ ...state, awaitingResponse: false, busy: false }))
return true
},
interruptSession: async runtimeId => {
// Read-only stored-transcript tiles have no live turn to interrupt.
if (isReadOnlyRuntimeId(runtimeId)) {
return
}
// Same cooldown as the primary chat's Stop (#83855): the gateway may
// still be winding down after this interrupt, so a quick edit/resend
// on the tile must go interrupt-first even though busy already reads
// false. Mark the runtime id (and any recovered id) before the RPC so
// the window covers the whole wind-down.
markSessionRecentlyInterrupted(runtimeId)
const storedSessionId = storedSessionIdForRuntime(runtimeId)
const routedRequest = storedSessionId
? <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) =>
requestForStoredSession<T>(storedSessionId, method, params ?? {}, timeoutMs)
: requestGateway
await withSessionNotFoundResume(
runtimeId,
storedSessionId,
liveId => routedRequest('session.interrupt', { session_id: liveId }),
{
requestGateway: routedRequest,
onRecovered: recoveredId => {
markSessionRecentlyInterrupted(recoveredId)
rebindTileRuntime(runtimeId)(recoveredId)
}
}
)
},
resumeTile: async (storedSessionId, options) => {
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cached = existing ? sessionStateByRuntimeIdRef.current.get(existing) : undefined
const refreshTranscript = options?.refreshTranscript === true
// Warm path: reuse a live binding — but only when it still carries a
// transcript (or is mid-turn, where messages legitimately stream in).
// A binding whose cached state has no messages is either a released
// transcript or a stale pre-reconnect survivor; reusing it painted the
// post-sleep/wake tile permanently empty. Fall through to a real
// resume instead — it's idempotent for a genuinely live session.
//
// Explicit reopen (`refreshTranscript`) must still REST-merge: the
// warm snapshot is whatever the tile last painted, and cron bot-chat
// deliveries that landed while the panel's WS was down never arrive
// as realtime events (#96183).
if (
existing &&
cached?.storedSessionId === storedSessionId &&
(cached.busy || cached.messages.length > 0) &&
!refreshTranscript
) {
publishSessionState(existing, cached)
return existing
}
// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const owner = await ownerForStoredSession(storedSessionId)
const restScope =
owner && typeof owner === 'object'
? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile }
: owner
const prefetchPromise = getLatestSessionMessages(storedSessionId, restScope).catch(() => null)
if (existing && cached?.storedSessionId === storedSessionId && (cached.busy || cached.messages.length > 0)) {
const prefetch = await prefetchPromise
const merged = mergeTileTranscript(cached.messages, prefetch?.messages)
if (!chatMessageArraysEquivalent(cached.messages, merged)) {
updateSessionState(existing, state => ({ ...state, messages: merged }), storedSessionId)
} else {
publishSessionState(existing, cached)
}
return existing
}
// #94724 no-owner recovery: dispatching the resume through the same
// fail-closed gate as the window's RPC dispatcher keeps an unknown
// owner off the ambient socket, and the wrapper opens the stored
// transcript read-only instead of dead-ending the tile — the id-only
// REST read routes no live session at all.
const outcome = await resumeWithStoredTranscriptFallback(
storedSessionId,
() => {
assertSessionOwnerResolved(owner, { method: 'session.resume', sessionId: storedSessionId })
return singleFlightSessionResume(storedSessionId, () =>
requestForSessionProfile<SessionResumeResponse>(owner, requestGateway, 'session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {})
})
)
},
async () => {
const stored = (await prefetchPromise) ?? (await fetchStoredTranscriptAcrossBackends(storedSessionId))
if (!stored) {
throw new Error('stored transcript unavailable on every reachable backend')
}
return stored
}
)
const prefetch = await prefetchPromise
if (outcome.mode === 'read-only') {
const readOnlyId = readOnlyRuntimeIdFor(storedSessionId)
updateSessionState(
readOnlyId,
state => ({
...state,
busy: false,
awaitingResponse: false,
messages: state.messages.length > 0 ? state.messages : toChatMessages(outcome.transcript?.messages ?? [])
}),
storedSessionId
)
notify({
kind: 'info',
title: translateNow('desktop.readOnlyTranscriptTitle'),
message: translateNow('desktop.readOnlyTranscriptBody')
})
return readOnlyId
}
const resumed = outcome.resumed
const runtimeId = resumed?.session_id
if (!runtimeId) {
throw new Error('resume returned no session id')
}
const info = resumed?.info
updateSessionState(
runtimeId,
state => ({
...state,
busy: Boolean(info?.running),
// Persist the session's own model/provider from resume so the tile
// pill does not wait on a chrome-scoped catalog read (#93892).
...(typeof info?.model === 'string' ? { model: info.model } : {}),
...(typeof info?.provider === 'string' ? { provider: info.provider } : {}),
...(typeof info?.reasoning_effort === 'string' ? { reasoningEffort: info.reasoning_effort } : {}),
...(typeof info?.fast === 'boolean' ? { fast: info.fast } : {}),
messages:
state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? [])
}),
storedSessionId
)
return runtimeId
},
submitToSession: async (runtimeId, text) => {
// A read-only stored-transcript tile has no live runtime to submit
// into (#94724). Refuse with the explanation instead of minting a
// misrouted prompt on a backend that never owned the session.
if (isReadOnlyRuntimeId(runtimeId)) {
notify({ kind: 'info', message: translateNow('desktop.readOnlyTranscriptSendBlocked') })
return
}
const storedSessionId = storedSessionIdForRuntime(runtimeId)
const routedRequest = storedSessionId
? <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) =>
requestForStoredSession<T>(storedSessionId, method, params ?? {}, timeoutMs)
: requestGateway
await withSessionNotFoundResume(
runtimeId,
storedSessionId,
liveId => routedRequest('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS),
{ requestGateway: routedRequest, onRecovered: rebindTileRuntime(runtimeId) }
)
},
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
})
}, [
archiveSession,
branchStoredSession,
executeSlashCommand,
removeSession,
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef,
updateSessionState
])
}