Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user