diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx
index 0c9aa9b175..7678cd13b9 100644
--- a/apps/desktop/src/app/chat/session-tile.tsx
+++ b/apps/desktop/src/app/chat/session-tile.tsx
@@ -232,13 +232,12 @@ function TileChat({
() =>
gatewayOpen ? (
) : null,
- [activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel]
+ [activeGatewayProfile, gatewayOpen, ownerRoute?.profile, ownerRoute?.targetProfile, requestTileGateway, selectModel]
)
return (
diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts
index 8bf3ffb50e..e99c262c88 100644
--- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts
@@ -206,6 +206,31 @@ describe('useSessionTileDelegate resumeTile', () => {
)
})
+ 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
+ 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' })])
diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
index ed49e99061..593017df64 100644
--- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
@@ -193,11 +193,19 @@ export function useSessionTileDelegate({
throw new Error('resume returned no session id')
}
+ const info = resumed?.info
+
updateSessionState(
runtimeId,
state => ({
...state,
- busy: Boolean(resumed?.info?.running),
+ 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 ?? [])
}),
diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
index 0241b7f8b8..034993e46e 100644
--- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
+++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
@@ -61,7 +61,9 @@ import {
import {
$attentionSessionIds,
$workingSessionIds,
+ $sessionTiles,
liveSessionScopes,
+ openTileGatewayScopes,
reconcileBusyStatesOnReconnect,
recordSessionEventScope,
resetTileRuntimeBindings
@@ -773,10 +775,13 @@ export function useGatewayBoot({
touchSecondaryGateways()
}, 60_000)
- // Bound concurrency cost to live work: keep a background socket only while
- // its profile has a running (working) or blocked (needs-input) session.
- // Once that profile goes idle its socket is dropped and its backend is free
- // to idle-reap. The active profile is always spared.
+ // Bound concurrency cost to consumers: keep a background socket while its
+ // profile has a running (working) or blocked (needs-input) session, OR an
+ // open owner-routed tile (Bot chats stay on a secondary while chrome stays
+ // on the launch profile). Once the last consumer leaves, the socket drops
+ // and its backend is free to idle-reap. The active profile is always spared.
+ // Do not key this off `entry.retained` — that flag only skips dispose-after-
+ // RPC; idle prune is what reclaims hover-warmed sockets after you leave.
const recomputeKeptGateways = () => {
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
// Registry-scoped (connectionId, profile) scopes with live work. Two
@@ -791,12 +796,17 @@ export function useGatewayBoot({
}
}
+ for (const scope of openTileGatewayScopes()) {
+ keep.add(scope)
+ }
+
pruneSecondaryGateways(keep)
}
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
+ const offTiles = $sessionTiles.subscribe(() => recomputeKeptGateways())
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
@@ -991,6 +1001,7 @@ export function useGatewayBoot({
offWorking()
offAttention()
offActiveProfile()
+ offTiles()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
window.removeEventListener('focus', onFocus)
diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx
index 1dfd70f15b..7e971437c6 100644
--- a/apps/desktop/src/app/shell/model-catalog-menu.tsx
+++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx
@@ -93,6 +93,9 @@ interface ModelCatalogMenuProps {
/** Rows appended under the catalog (Refresh Models, Edit Models, …). */
footer?: ReactNode
gateway?: HermesGateway
+ /** Owner-routed RPC for catalog reads. Preferred over `gateway.request` so
+ * a tile's menu queries the session owner's backend, not chrome's. */
+ request?: (method: string, params?: Record) => Promise
/** Render the virtual `moa` provider's presets as a selectable section.
* Off for override surfaces, where a MoA preset isn't a worker model. */
includeMoa?: boolean
@@ -122,6 +125,7 @@ export function ModelCatalogMenu({
gateway,
includeMoa = false,
profile = 'default',
+ request,
sessionId = null
}: ModelCatalogMenuProps) {
const { t } = useI18n()
@@ -141,7 +145,8 @@ export function ModelCatalogMenu({
// Gateway-first even with no session: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers the local
// REST fallback can't know about (#53817).
- queryFn: (): Promise => requestModelOptions({ gateway, sessionId })
+ queryFn: (): Promise =>
+ requestModelOptions({ gateway, profile, request, sessionId })
})
const loading = modelOptions.isPending && !modelOptions.data
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx
index 3aebe38de7..c65282ffca 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.tsx
@@ -73,7 +73,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId),
- queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId })
+ queryFn: (): Promise =>
+ requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId })
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
@@ -95,7 +96,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
try {
const queryKey = modelOptionsQueryKey(profile, activeSessionId)
- const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId })
+ const next = await requestModelOptions({
+ gateway,
+ profile,
+ refresh: true,
+ request: requestGateway,
+ sessionId: activeSessionId
+ })
queryClient.setQueryData(queryKey, next)
} catch {
@@ -238,6 +245,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
gateway={gateway}
includeMoa
profile={profile}
+ request={requestGateway}
sessionId={activeSessionId}
/>
)
diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts
index 4220d7494c..7ad3e23a2e 100644
--- a/apps/desktop/src/lib/model-options.test.ts
+++ b/apps/desktop/src/lib/model-options.test.ts
@@ -117,6 +117,46 @@ describe('requestModelOptions', () => {
expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true })
})
+
+ it('prefers an owner-routed request over the ambient gateway socket', async () => {
+ const gatewayPayload = {
+ model: 'chrome-model',
+ provider: 'nous',
+ providers: [{ models: ['chrome-model'], name: 'Nous', slug: 'nous' }]
+ }
+ const routedPayload = {
+ model: 'berry-model',
+ provider: 'openai',
+ providers: [{ models: ['berry-model'], name: 'OpenAI', slug: 'openai' }]
+ }
+ const gateway = {
+ request: vi.fn(() => Promise.resolve(gatewayPayload))
+ }
+ const request = vi.fn(() => Promise.resolve(routedPayload))
+
+ await expect(
+ requestModelOptions({ gateway: gateway as never, request, sessionId: 'tile-1' })
+ ).resolves.toBe(routedPayload)
+
+ expect(request).toHaveBeenCalledWith('model.options', { explicit_only: true, session_id: 'tile-1' })
+ expect(gateway.request).not.toHaveBeenCalled()
+ })
+
+ it('scopes REST recovery to the catalog owner profile', async () => {
+ const restPayload = {
+ model: 'berry-local',
+ provider: 'hermes-local',
+ providers: [{ models: ['berry-local'], name: 'Hermes Local', slug: 'hermes-local' }]
+ }
+ const request = vi.fn(() => Promise.reject(new Error('gateway request unavailable')))
+
+ vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload)
+
+ await expect(requestModelOptions({ profile: 'berry', request, sessionId: 'tile-1' })).resolves.toEqual(
+ restPayload
+ )
+ expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true }, 'berry')
+ })
})
describe('modelOptionsQueryKey', () => {
diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts
index 6add1bc684..d736402d7b 100644
--- a/apps/desktop/src/lib/model-options.ts
+++ b/apps/desktop/src/lib/model-options.ts
@@ -40,6 +40,13 @@ interface ModelOptionsRequest {
* providers are listed (#56974). */
explicitOnly?: boolean
gateway?: HermesGateway
+ /** Owner-routed RPC. When set, catalog reads hit this dispatcher instead of
+ * `gateway.request` — a tile's model menu must not query the ambient
+ * chrome socket (#93892). */
+ request?: (method: string, params?: Record) => Promise
+ /** Profile for the REST recovery path. Must match the catalog owner so a
+ * secondary tile does not fall back to the launch profile's models. */
+ profile?: null | string
refresh?: boolean
sessionId?: null | string
}
@@ -54,13 +61,28 @@ function hasSelectableModels(options: ModelOptionsResponse | null | undefined):
return options?.providers?.some(provider => (provider.models?.length ?? 0) > 0) ?? false
}
+function restModelOptions(
+ explicitOnly: boolean,
+ refresh: boolean,
+ profile?: null | string
+): Promise {
+ const opts = { explicitOnly, ...(refresh ? { refresh: true } : {}) }
+ const profileKey = (profile ?? '').trim()
+
+ return profileKey ? getGlobalModelOptions(opts, profileKey) : getGlobalModelOptions(opts)
+}
+
export async function requestModelOptions({
explicitOnly = true,
gateway,
+ profile,
refresh = false,
+ request,
sessionId
}: ModelOptionsRequest): Promise {
- if (gateway) {
+ const dispatch = request ?? (gateway ? gateway.request.bind(gateway) : null)
+
+ if (dispatch) {
const params: Record = {}
if (sessionId) {
@@ -79,7 +101,7 @@ export async function requestModelOptions({
let gatewayOptions: ModelOptionsResponse | undefined
try {
- gatewayOptions = await gateway.request('model.options', params)
+ gatewayOptions = await dispatch('model.options', params)
} catch (error) {
gatewayError = error
}
@@ -93,7 +115,7 @@ export async function requestModelOptions({
// catalog is already populated. Recover through the same profile-scoped
// endpoint Settings uses, but keep the live session selection authoritative.
try {
- const restOptions = await getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
+ const restOptions = await restModelOptions(explicitOnly, refresh, profile)
if (hasSelectableModels(restOptions)) {
return {
@@ -114,5 +136,5 @@ export async function requestModelOptions({
throw gatewayError
}
- return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
+ return restModelOptions(explicitOnly, refresh, profile)
}
diff --git a/apps/desktop/src/store/gateway-connection-scope.test.ts b/apps/desktop/src/store/gateway-connection-scope.test.ts
index c1a2954071..9c0105cb06 100644
--- a/apps/desktop/src/store/gateway-connection-scope.test.ts
+++ b/apps/desktop/src/store/gateway-connection-scope.test.ts
@@ -136,4 +136,17 @@ describe('pruneSecondaryGateways with registry-scoped entries', () => {
expect(gatewayMocks.closed).toHaveLength(1)
})
+
+ it("does not let a remote tile keep-set pin a local same-named secondary", async () => {
+ // Chrome is on another profile so 'default' is a real secondary, not the
+ // spared active key. A homelab bot tile keep-set must keep only the
+ // composite scope — the local 'default' socket still idles out.
+ setPrimaryGateway({ connectionState: 'open' } as never, 'research')
+ await openGatewayForAgent(null, 'default')
+ await openGatewayForAgent('homelab', 'default')
+
+ pruneSecondaryGateways(new Set(['conn:homelab::default']))
+
+ expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?token=t'])
+ })
})
diff --git a/apps/desktop/src/store/session-states-scopes.test.ts b/apps/desktop/src/store/session-states-scopes.test.ts
index 437895f435..55169e3ba6 100644
--- a/apps/desktop/src/store/session-states-scopes.test.ts
+++ b/apps/desktop/src/store/session-states-scopes.test.ts
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import {
$sessionStates,
+ $sessionTiles,
clearAllSessionStates,
dropSessionState,
liveSessionScopes,
+ openTileGatewayScopes,
publishSessionState,
recordSessionEventScope
} from '@/store/session-states'
@@ -79,3 +81,59 @@ describe('liveSessionScopes', () => {
expect(liveSessionScopes()).toEqual(new Set())
})
})
+
+describe('openTileGatewayScopes', () => {
+ beforeEach(() => {
+ $sessionTiles.set([])
+ })
+
+ afterEach(() => {
+ $sessionTiles.set([])
+ })
+
+ it('keeps a local bot tile on both the bare profile and the explicit local registry scope', () => {
+ $sessionTiles.set([
+ {
+ ownerRoute: { connectionId: 'local', mode: 'local', profile: 'berry' },
+ storedSessionId: 'bot-chat-berry'
+ }
+ ])
+
+ expect(openTileGatewayScopes()).toEqual(new Set(['berry', 'conn:local::berry']))
+ })
+
+ it('keeps a remote tile on its composite scope only', () => {
+ $sessionTiles.set([
+ {
+ ownerRoute: { connectionId: 'homelab', profile: 'default' },
+ storedSessionId: 'bot-chat-homelab'
+ }
+ ])
+
+ expect(openTileGatewayScopes()).toEqual(new Set(['conn:homelab::default']))
+ expect(openTileGatewayScopes().has('default')).toBe(false)
+ })
+
+ it('keys the keep-set on route.profile, not a remapped targetProfile', () => {
+ // openGatewayForAgent dials (connectionId, profile). targetProfile only
+ // rewrites RPC params — using it here would miss the live socket.
+ $sessionTiles.set([
+ {
+ ownerRoute: {
+ connectionId: 'barry',
+ profile: 'oxcoder',
+ targetProfile: 'backend-oxcoder'
+ },
+ storedSessionId: 'bot-chat-oxcoder'
+ }
+ ])
+
+ expect(openTileGatewayScopes()).toEqual(new Set(['conn:barry::oxcoder']))
+ })
+
+ it('ignores tiles without an owner route', () => {
+ $sessionTiles.set([{ storedSessionId: 'plain' }])
+
+ expect(openTileGatewayScopes()).toEqual(new Set())
+ })
+})
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index fc3c89039f..3ea60a20ea 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -16,7 +16,7 @@
* itself here as the delegate so tile UI stays dependency-light.
*/
-import { registryBackendScopeKey } from '@hermes/shared'
+import { LOCAL_CONNECTION_ID, registryBackendScopeKey } from '@hermes/shared'
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
@@ -742,6 +742,41 @@ export function sessionTileOwnerRoute(storedSessionId: string): SessionProfileRo
return $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.ownerRoute
}
+/**
+ * Gateway keep-set scopes for currently open tiles. Bot chats (and any other
+ * owner-routed tile) hold a secondary socket even while chrome stays on the
+ * launch profile; without these keys, idle prune closes that socket and the
+ * tile's resume/unbind loop spins forever. Local routes contribute both the
+ * bare profile (openGatewayForProfile) and the explicit `conn:local::…` key
+ * (openGatewayForAgent). Remote routes contribute only the composite key so
+ * a homelab tile cannot pin another source's same-named profile.
+ */
+export function openTileGatewayScopes(): Set {
+ const scopes = new Set()
+
+ for (const tile of $sessionTiles.get()) {
+ const route = tile.ownerRoute
+
+ if (!route) {
+ continue
+ }
+
+ const profile = normalizeProfileKey(route.profile)
+ const connectionId = String(route.connectionId ?? '').trim()
+ const localRoute = !connectionId || connectionId === LOCAL_CONNECTION_ID || route.mode === 'local'
+
+ if (localRoute) {
+ scopes.add(profile)
+ }
+
+ if (connectionId) {
+ scopes.add(registryBackendScopeKey(connectionId, profile))
+ }
+ }
+
+ return scopes
+}
+
/**
* Sync owner resolution for a session id that may be a RUNTIME or a STORED id.
* Tile route first (exact connectionId+profile, survives relaunch), then the
diff --git a/contributors/emails/1244224501@qq.com b/contributors/emails/1244224501@qq.com
new file mode 100644
index 0000000000..a3932e8913
--- /dev/null
+++ b/contributors/emails/1244224501@qq.com
@@ -0,0 +1 @@
+LovePlayCode