diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
index 271a235b21c8..d81afb7ed521 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.test.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
@@ -1,7 +1,9 @@
import { cleanup, render, screen } from '@testing-library/react'
+import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
+import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
import { ModelPill } from './model-pill'
@@ -28,7 +30,7 @@ describe('ModelPill pinned-override badge', () => {
setCurrentModelSource('manual')
$activeSessionId.set(null)
- render()
+ render()
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
})
@@ -59,13 +61,55 @@ describe('ModelPill pinned-override badge', () => {
$activeSessionId.set(null)
// Fallback (no live menu) path.
- const { unmount } = render()
+ const { unmount } = render(
+
+ )
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
- render( })} />)
+ render(
+ })}
+ />
+ )
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
})
})
+
+describe('ModelPill per-surface model label', () => {
+ it('shows the chat-bar model even when the primary global differs', () => {
+ setCurrentModel('primary/model')
+ $activeSessionId.set('primary-runtime')
+
+ const tileView: SessionView = {
+ kind: 'tile',
+ $awaitingResponse: atom(false),
+ $busy: atom(false),
+ $cwd: atom(''),
+ $fast: atom(false),
+ $lastVisibleIsUser: atom(false),
+ $messages: atom([]),
+ $messagesEmpty: atom(true),
+ $model: atom('tile/claude-sonnet'),
+ $provider: atom('anthropic'),
+ $reasoningEffort: atom('high'),
+ $runtimeId: atom('tile-runtime'),
+ $storedId: atom('stored-tile')
+ }
+
+ render(
+
+ })}
+ />
+
+ )
+
+ expect(screen.getByText('Sonnet · High')).toBeTruthy()
+ expect(screen.queryByText(/primary/i)).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx
index a6c95ffc18c4..b5a6e976805b 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.tsx
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
+import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -10,15 +11,7 @@ import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
-import {
- $activeSessionId,
- $currentFastMode,
- $currentModel,
- $currentModelSource,
- $currentProvider,
- $currentReasoningEffort,
- setModelPickerOpen
-} from '@/store/session'
+import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import type { ChatBarState } from './types'
@@ -31,6 +24,9 @@ const PILL = cn(
* Composer model selector — the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
+ *
+ * Display follows THIS surface's SessionView (primary or tile) — never the
+ * primary-only globals — so side-by-side panes each show their own model.
*/
export function ModelPill({
compact = false,
@@ -42,12 +38,17 @@ export function ModelPill({
model: ChatBarState['model']
}) {
const copy = useI18n().t.shell.statusbar
- const currentModel = useStore($currentModel)
- const currentProvider = useStore($currentProvider)
- const fastMode = useStore($currentFastMode)
- const reasoningEffort = useStore($currentReasoningEffort)
+ const view = useSessionView()
+ // Prefer the chat-bar snapshot (already view-scoped by ChatView); fall back
+ // to the live SessionView atoms so a mid-flight session.info still paints.
+ const viewModel = useStore(view.$model)
+ const viewProvider = useStore(view.$provider)
+ const currentModel = model.model || viewModel
+ const currentProvider = model.provider || viewProvider
+ const fastMode = useStore(view.$fast)
+ const reasoningEffort = useStore(view.$reasoningEffort)
const modelSource = useStore($currentModelSource)
- const activeSessionId = useStore($activeSessionId)
+ const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
// The composer pick is sticky: a manual selection is pinned and every NEW
@@ -55,7 +56,9 @@ export function ModelPill({
// cost users real money on a forgotten paid-model pick (#62055). Surface the
// pin whenever a draft (no live session) is running on a manual override. A
// live session's footer reflects that session's model, so no badge there.
- const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
+ // Tiles always have a runtime — pin badge is primary-draft only.
+ const pinnedOverride =
+ view.kind === 'primary' && !runtimeId && modelSource === 'manual' && Boolean(currentModel.trim())
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx
index 1a9ee67ac0cd..efb326ebdc29 100644
--- a/apps/desktop/src/app/chat/session-tile.tsx
+++ b/apps/desktop/src/app/chat/session-tile.tsx
@@ -15,11 +15,14 @@
*/
import { useStore } from '@nanostores/react'
+import { useQueryClient } from '@tanstack/react-query'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
+import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
+import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
@@ -84,11 +87,13 @@ function buildTileView(storedSessionId: string): SessionView {
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
$busy: computed($state, state => Boolean(state?.busy)),
$cwd: computed($state, state => state?.cwd ?? ''),
+ $fast: computed($state, state => Boolean(state?.fast)),
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
$messages,
$messagesEmpty: computed($messages, messages => messages.length === 0),
$model: computed($state, state => state?.model ?? ''),
$provider: computed($state, state => state?.provider ?? ''),
+ $reasoningEffort: computed($state, state => state?.reasoningEffort ?? ''),
$runtimeId,
// Constant for the tile's lifetime — a plain atom, not a computed.
$storedId: atom(storedSessionId)
@@ -105,7 +110,10 @@ function TileChat({
view: SessionView
}) {
const { gatewayRef, requestGateway } = useGatewayRequest()
+ const queryClient = useQueryClient()
+ const { selectModel } = useModelControls({ queryClient, requestGateway })
const cwd = useStore(view.$cwd)
+ const gatewayOpen = useStore($gatewayState) === 'open'
// One attachment set + focus key per tile, stable for the tile's lifetime.
const attachments = useRef(createComposerAttachmentScope()).current
@@ -132,11 +140,26 @@ function TileChat({
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
})
+ // Per-tile model menu — rendered under this tile's SessionView so the pill
+ // + switch target THIS runtime, not the primary (which may be mid-turn).
+ const modelMenuContent = useMemo(
+ () =>
+ gatewayOpen ? (
+
+ ) : null,
+ [gatewayOpen, gatewayRef, requestGateway, selectModel]
+ )
+
return (
composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx
index af72d0ceb41e..9ca39ace67e8 100644
--- a/apps/desktop/src/app/chat/session-view.tsx
+++ b/apps/desktop/src/app/chat/session-view.tsx
@@ -7,8 +7,10 @@ import {
$awaitingResponse,
$busy,
$currentCwd,
+ $currentFastMode,
$currentModel,
$currentProvider,
+ $currentReasoningEffort,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
@@ -38,6 +40,8 @@ export interface SessionView {
$cwd: ReadableAtom
$model: ReadableAtom
$provider: ReadableAtom
+ $fast: ReadableAtom
+ $reasoningEffort: ReadableAtom
}
export const PRIMARY_SESSION_VIEW: SessionView = {
@@ -45,11 +49,13 @@ export const PRIMARY_SESSION_VIEW: SessionView = {
$awaitingResponse,
$busy,
$cwd: $currentCwd,
+ $fast: $currentFastMode,
$lastVisibleIsUser: $lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$model: $currentModel,
$provider: $currentProvider,
+ $reasoningEffort: $currentReasoningEffort,
$runtimeId: $activeSessionId,
$storedId: $selectedStoredSessionId
}
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index fe77f99e15cc..fa9d0b44448c 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -918,7 +918,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
setCurrentProvider(provider)
setCurrentModel(model)
setCurrentModelSource('default')
- updateModelOptionsCache(provider, model, true)
+ updateModelOptionsCache($activeSessionId.get(), provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx
index 4921ad688459..51d7e783afbc 100644
--- a/apps/desktop/src/app/model-picker-overlay.tsx
+++ b/apps/desktop/src/app/model-picker-overlay.tsx
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
-import type * as React from 'react'
+import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import {
@@ -11,19 +11,28 @@ import {
$modelPickerOpen,
setModelPickerOpen
} from '@/store/session'
+import { $focusedRuntimeId, $focusedSessionState } from '@/store/session-states'
interface ModelPickerOverlayProps {
gateway?: HermesGateway
- onSelect: React.ComponentProps['onSelect']
+ onSelect: (selection: ModelSelection) => void
}
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
- const activeSessionId = useStore($activeSessionId)
- const currentModel = useStore($currentModel)
- const currentProvider = useStore($currentProvider)
+ const primarySessionId = useStore($activeSessionId)
+ const primaryModel = useStore($currentModel)
+ const primaryProvider = useStore($currentProvider)
+ const focusedRuntimeId = useStore($focusedRuntimeId)
+ const focusedState = useStore($focusedSessionState)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)
+ // Prefer the focused tile's runtime when the overlay opens from a tile that
+ // lacked a live menu (gateway closed → fallback path).
+ const sessionId = focusedRuntimeId ?? primarySessionId
+ const currentModel = focusedRuntimeId && focusedState ? focusedState.model : primaryModel
+ const currentProvider = focusedRuntimeId && focusedState ? focusedState.provider : primaryProvider
+
if (!gatewayOpen) {
return null
}
@@ -34,9 +43,9 @@ export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProp
currentProvider={currentProvider}
gw={gateway}
onOpenChange={setModelPickerOpen}
- onSelect={onSelect}
+ onSelect={selection => onSelect({ ...selection, sessionId })}
open={open}
- sessionId={activeSessionId}
+ sessionId={sessionId}
/>
)
}
diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
index f5ab213d0450..41f7d77d136e 100644
--- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
@@ -12,6 +12,7 @@ import {
setCurrentModelSource,
setCurrentProvider
} from '@/store/session'
+import type * as SessionStates from '@/store/session-states'
import { useModelControls } from './use-model-controls'
@@ -30,9 +31,19 @@ function deferred() {
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
+ setApiRequestProfile: vi.fn(),
setGlobalModel: (...args: Parameters) => setGlobalModel(...args)
}))
+vi.mock('@/store/session-states', async importOriginal => {
+ const actual = await importOriginal()
+
+ return {
+ ...actual,
+ sessionTileDelegate: () => null
+ }
+})
+
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
@@ -333,4 +344,31 @@ describe('useModelControls', () => {
expect($currentProvider.get()).toBe('openai-codex')
expect(getCurrentModelSource()).toBe('default')
})
+
+ it('targets an explicit tile sessionId without clobbering the primary model', async () => {
+ $activeSessionId.set('primary-runtime')
+ setCurrentModel('primary/model')
+ setCurrentProvider('openai')
+ const requestGateway = vi.fn(async () => ({ key: 'model', value: 'tile-model' }) as never)
+ let controls!: Controls
+
+ render( (controls = value)} requestGateway={requestGateway} />)
+
+ await expect(
+ controls.selectModel({
+ model: 'tile-model',
+ provider: 'anthropic',
+ sessionId: 'tile-runtime'
+ })
+ ).resolves.toBe(true)
+
+ expect(requestGateway).toHaveBeenCalledWith('config.set', {
+ session_id: 'tile-runtime',
+ key: 'model',
+ value: 'tile-model --provider anthropic --session'
+ })
+ // Primary footer untouched — the busy primary must not absorb a tile pick.
+ expect($currentModel.get()).toBe('primary/model')
+ expect($currentProvider.get()).toBe('openai')
+ })
})
diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts
index c7f4d245cb71..c833d3a3e15f 100644
--- a/apps/desktop/src/app/session/hooks/use-model-controls.ts
+++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts
@@ -1,6 +1,7 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback, useRef } from 'react'
+import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { manualPickRemoved } from '@/lib/model-options'
@@ -16,13 +17,9 @@ import {
setCurrentModelSource,
setCurrentProvider
} from '@/store/session'
+import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
import type { ModelOptionsResponse } from '@/types/hermes'
-interface ModelSelection {
- model: string
- provider: string
-}
-
interface ModelControlsOptions {
queryClient: QueryClient
requestGateway: (method: string, params?: Record) => Promise
@@ -39,10 +36,10 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
// callbacks once and never re-evaluate — a captured prop would be stale
// forever. The store read is always current.
const updateModelOptionsCache = useCallback(
- (provider: string, model: string, includeGlobal: boolean) => {
+ (sessionId: null | string, provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
- queryClient.setQueryData(['model-options', $activeSessionId.get() || 'global'], patch)
+ queryClient.setQueryData(['model-options', sessionId || 'global'], patch)
if (includeGlobal) {
queryClient.setQueryData(['model-options', 'global'], patch)
@@ -129,21 +126,39 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
+ //
+ // `selection.sessionId` targets a specific surface (tile). When omitted, the
+ // primary `$activeSessionId` is used (overlay / legacy callers). A tile
+ // switch must not touch the primary globals — and must not be blocked by a
+ // busy primary turn.
const selectModel = useCallback(
async (selection: ModelSelection): Promise => {
- // Snapshot for rollback: the switch is applied optimistically, so a
- // failure must restore the prior model/provider (store + query cache)
- // rather than leave the UI showing a model the backend never selected.
- const prevModel = $currentModel.get()
- const prevProvider = $currentProvider.get()
+ const primaryRuntimeId = $activeSessionId.get()
+ const liveSessionId = 'sessionId' in selection ? (selection.sessionId ?? null) : primaryRuntimeId
+ const touchesPrimary = !liveSessionId || liveSessionId === primaryRuntimeId
+
+ const prevModel = touchesPrimary ? $currentModel.get() : ($sessionStates.get()[liveSessionId!]?.model ?? '')
+
+ const prevProvider = touchesPrimary
+ ? $currentProvider.get()
+ : ($sessionStates.get()[liveSessionId!]?.provider ?? '')
+
const prevSource = getCurrentModelSource()
- const liveSessionId = $activeSessionId.get()
+ if (touchesPrimary) {
+ setCurrentModel(selection.model)
+ setCurrentProvider(selection.provider)
+ markComposerSelectionManual()
+ } else if (liveSessionId) {
+ // Optimistic tile paint — session.info will confirm; rollback on error.
+ sessionTileDelegate()?.updateSession(liveSessionId, state => ({
+ ...state,
+ model: selection.model,
+ provider: selection.provider
+ }))
+ }
- setCurrentModel(selection.model)
- setCurrentProvider(selection.provider)
- markComposerSelectionManual()
- updateModelOptionsCache(selection.provider, selection.model, !liveSessionId)
+ updateModelOptionsCache(liveSessionId, selection.provider, selection.model, touchesPrimary && !liveSessionId)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
@@ -162,10 +177,19 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return true
} catch (err) {
- setCurrentModel(prevModel)
- setCurrentProvider(prevProvider)
- setCurrentModelSource(prevSource)
- updateModelOptionsCache(prevProvider, prevModel, !liveSessionId)
+ if (touchesPrimary) {
+ setCurrentModel(prevModel)
+ setCurrentProvider(prevProvider)
+ setCurrentModelSource(prevSource)
+ } else if (liveSessionId) {
+ sessionTileDelegate()?.updateSession(liveSessionId, state => ({
+ ...state,
+ model: prevModel,
+ provider: prevProvider
+ }))
+ }
+
+ updateModelOptionsCache(liveSessionId, prevProvider, prevModel, touchesPrimary && !liveSessionId)
notifyError(err, copy.modelSwitchFailed)
return false
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
index c46e38aa240f..4e552303b572 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
@@ -7,6 +7,7 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
+import type * as HermesApi from '@/hermes'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import {
$activeSessionId,
@@ -20,6 +21,12 @@ import {
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
+vi.mock('@/hermes', async importOriginal => {
+ const actual = await importOriginal()
+
+ return { ...actual, setApiRequestProfile: vi.fn() }
+})
+
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx
index 527c84cec45c..742ed304b65b 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx
@@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
+import { useSessionView } from '@/app/chat/session-view'
import {
DropdownMenuItem,
DropdownMenuLabel,
@@ -15,12 +16,8 @@ import { useI18n } from '@/i18n'
import { normalize } from '@/lib/text'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
-import {
- $activeSessionId,
- markComposerSelectionManual,
- setCurrentFastMode,
- setCurrentReasoningEffort
-} from '@/store/session'
+import { markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
+import { sessionTileDelegate } from '@/store/session-states'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
@@ -110,14 +107,17 @@ export function ModelEditSubmenu({
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
- const activeSessionId = useStore($activeSessionId)
+ const view = useSessionView()
+ const activeSessionId = useStore(view.$runtimeId)
+ const touchesPrimary = view.kind === 'primary'
const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(effort)
- // Editing always records the model's global preset; the active model also gets
- // it pushed onto the live session. Non-active edits stay preset-only — they do
- // not switch you to that model.
+ // Editing always records the model's global preset (keyed by provider::model,
+ // not per-surface — a tile edit re-applies to that model everywhere); the
+ // active model also gets it pushed onto its OWN session (primary → globals,
+ // tile → its slice). Non-active edits stay preset-only — no model switch.
const patchReasoning = async (next: string) => {
setModelPreset(provider, model, { effort: next })
@@ -125,8 +125,12 @@ export function ModelEditSubmenu({
return
}
- markComposerSelectionManual()
- setCurrentReasoningEffort(next)
+ if (touchesPrimary) {
+ markComposerSelectionManual()
+ setCurrentReasoningEffort(next)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
+ }
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
@@ -139,7 +143,12 @@ export function ModelEditSubmenu({
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
- setCurrentReasoningEffort(effort)
+ if (touchesPrimary) {
+ setCurrentReasoningEffort(effort)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort }))
+ }
+
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed)
}
@@ -167,8 +176,12 @@ export function ModelEditSubmenu({
return
}
- markComposerSelectionManual()
- setCurrentFastMode(enabled)
+ if (touchesPrimary) {
+ markComposerSelectionManual()
+ setCurrentFastMode(enabled)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
+ }
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
@@ -182,7 +195,12 @@ export function ModelEditSubmenu({
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
- setCurrentFastMode(!enabled)
+ if (touchesPrimary) {
+ setCurrentFastMode(!enabled)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
+ }
+
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed)
}
diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx
index 7ca78274f343..f51c3b0e5672 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx
@@ -17,7 +17,8 @@ beforeAll(() => {
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
- getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args)
+ getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
+ setApiRequestProfile: vi.fn()
}))
// MoA presets now arrive as the catalog's virtual `moa` provider row (the same
@@ -64,7 +65,7 @@ describe('ModelMenuPanel MoA presets', () => {
// #54670: must route through the persistent model-switch path
// i.e. onSelectModel with provider 'moa' (which session-scopes live-session
// switches), NOT a one-shot command.dispatch that reverts after a turn.
- expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })
+ expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: 'runtime-1' })
})
it('shows the check on the preset that matches the current moa selection', async () => {
@@ -103,6 +104,6 @@ describe('ModelMenuPanel MoA presets', () => {
// Pre-session picks are UI state shipped on the next session.create — the
// row must not be disabled and must still route through onSelectModel.
- expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })
+ expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: null })
})
})
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx
index eec7385af8c9..7091f29d1ac0 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.tsx
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
+import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenuGroup,
@@ -36,13 +37,6 @@ import {
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
-import {
- $activeSessionId,
- $currentFastMode,
- $currentModel,
- $currentProvider,
- $currentReasoningEffort
-} from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
@@ -52,9 +46,17 @@ import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
+export interface ModelSelection {
+ model: string
+ provider: string
+ /** Runtime id of the surface that opened the menu. When set, the switch
+ * targets that session (a tile) instead of the primary `$activeSessionId`. */
+ sessionId?: null | string
+}
+
interface ModelMenuPanelProps {
gateway?: HermesGateway
- onSelectModel: (selection: { model: string; provider: string }) => Promise | void
+ onSelectModel: (selection: ModelSelection) => Promise | void
requestGateway: (method: string, params?: Record) => Promise
}
@@ -70,14 +72,14 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
- // Reactive session state is read from the stores here (not drilled in), so
- // toggling effort/fast/model re-renders this panel in place without forcing
- // the parent to rebuild the menu content (which would close the dropdown).
- const activeSessionId = useStore($activeSessionId)
- const currentFastMode = useStore($currentFastMode)
- const currentModel = useStore($currentModel)
- const currentProvider = useStore($currentProvider)
- const currentReasoningEffort = useStore($currentReasoningEffort)
+ // Bind to THIS surface's SessionView (primary or tile) so each pane's menu
+ // shows/switches its own model — not the primary-only globals.
+ const view = useSessionView()
+ const activeSessionId = useStore(view.$runtimeId)
+ const currentFastMode = useStore(view.$fast)
+ const currentModel = useStore(view.$model)
+ const currentProvider = useStore(view.$provider)
+ const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
@@ -126,7 +128,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
- const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
+ // Always stamp sessionId from this surface so a tile switch never hits the
+ // primary (busy) session by accident.
+ const switchTo = (model: string, provider: string) =>
+ onSelectModel({ model, provider, sessionId: activeSessionId || null })
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
@@ -175,7 +180,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
- { failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
+ {
+ failMessage: t.shell.modelOptions.updateFailed,
+ primary: view.kind === 'primary',
+ request: requestGateway,
+ sessionId: activeSessionId
+ }
)
}
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts
new file mode 100644
index 000000000000..198359df18ae
--- /dev/null
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+
+import { forceLoneHeaderForPanes } from './lone-header'
+
+describe('forceLoneHeaderForPanes', () => {
+ const chrome =
+ (placement?: string, uncloseable = false) =>
+ () => ({ placement, uncloseable })
+ const noCollapse = () => false
+
+ it('forces a header for session-tile ids even without registered chrome', () => {
+ expect(forceLoneHeaderForPanes(['session-tile:abc'], () => ({}), noCollapse)).toBe(true)
+ })
+
+ it('forces a header for closeable placement:main panes', () => {
+ expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
+ expect(forceLoneHeaderForPanes(['some-page'], chrome('main', false), noCollapse)).toBe(true)
+ })
+
+ it('forces a header for a lone collapse tool pane', () => {
+ expect(
+ forceLoneHeaderForPanes(
+ ['terminal'],
+ () => ({}),
+ id => id === 'terminal'
+ )
+ ).toBe(true)
+ })
+
+ it('leaves a lone uncloseable workspace headerless', () => {
+ expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts
new file mode 100644
index 000000000000..1a0ab4c07359
--- /dev/null
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts
@@ -0,0 +1,36 @@
+/**
+ * When a lone pane must keep its tab strip (name card + close).
+ *
+ * Default: a single pane isn't a "tab", so the header auto-hides. Exceptions
+ * force it on so a closeable surface never becomes an unclosable dead zone:
+ * - session tiles (`session-tile:*`) — even before chrome registers
+ * - any closeable `placement: 'main'` contribution
+ * - a collapse tool panel dragged into its own zone
+ */
+
+export interface LoneHeaderChrome {
+ placement?: string
+ uncloseable?: boolean
+}
+
+export function forceLoneHeaderForPanes(
+ shown: readonly string[],
+ chromeOf: (id: string) => LoneHeaderChrome,
+ isCollapsePane: (id: string) => boolean
+): boolean {
+ if (shown.some(id => id.startsWith('session-tile:'))) {
+ return true
+ }
+
+ if (
+ shown.some(id => {
+ const chrome = chromeOf(id)
+
+ return !chrome.uncloseable && chrome.placement === 'main'
+ })
+ ) {
+ return true
+ }
+
+ return shown.length === 1 && isCollapsePane(shown[0])
+}
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
index ff15a790bc83..8ba225a4bad8 100644
--- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
@@ -52,6 +52,7 @@ import {
} from '../store'
import { type DoubleTapContext, startPaneDrag } from './drag-session'
+import { forceLoneHeaderForPanes } from './lone-header'
import { paneChrome } from './track-model'
/** A directional action in the zone menu (computed per group state). */
@@ -187,13 +188,9 @@ export function TreeGroup({
// The uncloseable workspace and side chrome (sessions/files) keep the clean
// no-tab default. Double-click toggles it either way; a minimized group
// always shows its header (it IS the header).
- const forceLoneHeader =
- shown.some(id => {
- const chrome = paneChrome(paneFor(id))
-
- return !chrome.uncloseable && chrome.placement === 'main'
- }) ||
- (shown.length === 1 && isCollapsePane(shown[0]))
+ // Session-tile ids force the header even before chrome registers — cycling
+ // onto a freshly-split tile used to land headerless ("name card missing").
+ const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane)
// A full-page view (headerVeto) suppresses the strip while it's the active
// pane — a page is not a tab-able surface; the bar returns with the chat.
diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts
index 4ead79c098f7..5013e9f2ddd8 100644
--- a/apps/desktop/src/components/pane-shell/tree/store.ts
+++ b/apps/desktop/src/components/pane-shell/tree/store.ts
@@ -375,7 +375,15 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
}
const idx = Math.max(0, panes.indexOf(group!.active ?? ''))
- activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length])
+ const nextId = panes[(idx + direction + panes.length) % panes.length]
+ activateTreePane(group!.id, nextId)
+
+ // Cycling onto a session/main tab must surface the name card — a zone that
+ // was double-tap-hidden stays headerless otherwise ("the one that cycles
+ // never gets it").
+ if (nextId === 'workspace' || nextId.startsWith('session-tile:')) {
+ setTreeGroupHeaderHidden(group!.id, false)
+ }
return true
}
diff --git a/apps/desktop/src/store/model-presets.ts b/apps/desktop/src/store/model-presets.ts
index 8771b38e0827..ea9fc837eefd 100644
--- a/apps/desktop/src/store/model-presets.ts
+++ b/apps/desktop/src/store/model-presets.ts
@@ -4,6 +4,7 @@ import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
+import { sessionTileDelegate } from './session-states'
const STORAGE_KEY = 'hermes.desktop.model-presets'
@@ -55,17 +56,28 @@ export function setModelPreset(provider: string, model: string, patch: ModelPres
* `undefined` skips that dimension; values are capability-gated upstream.
* Without a session the local draft still needs the preset, but must not call
* `config.set`: that falls back to persistent profile config when no session
- * matches and would rewrite the user's defaults. */
+ * matches and would rewrite the user's defaults.
+ *
+ * `primary: false` scopes the optimistic write to the tile's session slice —
+ * a tile's picker must not clobber the primary composer's effort/fast. */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
- ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
+ ctx: { failMessage: string; primary?: boolean; request: RequestGateway; sessionId: null | string }
): Promise {
- if (effort !== undefined) {
- setCurrentReasoningEffort(effort)
- }
+ if (ctx.primary ?? true) {
+ if (effort !== undefined) {
+ setCurrentReasoningEffort(effort)
+ }
- if (fast !== undefined) {
- setCurrentFastMode(fast)
+ if (fast !== undefined) {
+ setCurrentFastMode(fast)
+ }
+ } else if (ctx.sessionId) {
+ sessionTileDelegate()?.updateSession(ctx.sessionId, state => ({
+ ...state,
+ ...(effort !== undefined ? { reasoningEffort: effort } : {}),
+ ...(fast !== undefined ? { fast } : {})
+ }))
}
if (!ctx.sessionId) {
diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts
new file mode 100644
index 000000000000..682ebe423d34
--- /dev/null
+++ b/apps/desktop/src/store/session-states.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+
+import { group, split } from '@/components/pane-shell/tree/model'
+import type { SessionTile } from '@/store/session-states'
+import { orderTilesByTree, selectionHomesToWorkspace } from '@/store/session-states'
+
+const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
+const tilePane = (id: string) => `session-tile:${id}`
+
+describe('orderTilesByTree', () => {
+ it('no-ops (null) without a tree or below two tiles', () => {
+ expect(orderTilesByTree(null, [tile('a'), tile('b')])).toBeNull()
+ expect(orderTilesByTree(group([tilePane('a')]), [tile('a')])).toBeNull()
+ })
+
+ it('reorders tiles to layout-tree encounter order across a split', () => {
+ const tree = split('row', [group(['workspace', tilePane('b')]), group([tilePane('a')])])
+
+ expect(orderTilesByTree(tree, [tile('a'), tile('b')])).toEqual([tile('b'), tile('a')])
+ })
+
+ it('returns null when the array already matches strip order (skip persist)', () => {
+ const tree = split('row', [group([tilePane('b')]), group([tilePane('a')])])
+
+ expect(orderTilesByTree(tree, [tile('b'), tile('a')])).toBeNull()
+ })
+
+ it('sorts not-yet-adopted tiles after placed ones, stably', () => {
+ const tree = group(['workspace', tilePane('b')])
+
+ expect(orderTilesByTree(tree, [tile('a'), tile('b'), tile('c')])).toEqual([tile('b'), tile('a'), tile('c')])
+ })
+})
+
+describe('selectionHomesToWorkspace', () => {
+ const tiles = [tile('a'), tile('b')]
+
+ it('homes for a null selection or a non-tile session', () => {
+ expect(selectionHomesToWorkspace(null, tiles)).toBe(true)
+ expect(selectionHomesToWorkspace('c', tiles)).toBe(true)
+ })
+
+ it('skips homing when the selected id is already an open tile', () => {
+ expect(selectionHomesToWorkspace('a', tiles)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index de9a50c2431f..7ebfc3a9d407 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -19,7 +19,7 @@
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
-import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model'
+import { findGroup, findGroupOfPane, type LayoutNode } from '@/components/pane-shell/tree/model'
import {
$activeTreeGroup,
$layoutTree,
@@ -250,10 +250,11 @@ export interface SessionTile {
/** Dock against `anchor` on adoption (default right; center = stack). */
dir?: TileDock
/** Pane to dock against (a drop's target zone) — default the workspace.
- * In-memory only: after first adoption the tree remembers placement. */
+ * Persisted so a restart re-docks in place; a stale id falls back to the
+ * workspace (findGroupOfPane misses → the move is skipped). */
anchor?: string
- /** Center docks: stack BEFORE this pane id (`null`/omitted = append) —
- * the strip divider's slot. In-memory, like `anchor`. */
+ /** Center docks: stack BEFORE this pane id (`null`/omitted = append) — the
+ * strip divider's slot. Persisted, like `anchor`; a stale id appends. */
before?: null | string
/** Live runtime id once the tile's resume has bound one. */
runtimeId?: string
@@ -269,16 +270,34 @@ export interface SessionTile {
// "stale runtime after respawn" bugs by construction).
const TILES_KEY = 'hermes.desktop.sessionTiles.v2'
const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1'
+const TILE_PANE_PREFIX = 'session-tile:'
-type StoredTile = Pick
+/** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a
+ * restart / profile swap re-adopts tiles in the same order, not all stacked
+ * right of workspace. */
+type StoredTile = Pick
-const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId })
+const toStored = (t: SessionTile): StoredTile => ({
+ anchor: t.anchor,
+ before: t.before,
+ dir: t.dir,
+ storedSessionId: t.storedSessionId
+})
function parseTileList(value: unknown): StoredTile[] {
return Array.isArray(value)
? value
.filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string'))
- .map(toStored)
+ .map(t => {
+ const raw = t as SessionTile
+
+ return {
+ anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined,
+ before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined,
+ dir: raw.dir,
+ storedSessionId: raw.storedSessionId
+ }
+ })
: []
}
@@ -407,6 +426,56 @@ export function sessionTileDelegate(): SessionTileDelegate | null {
return delegate
}
+/** Reorder tiles to match layout-tree encounter order (stored ids in the order
+ * their `session-tile:` panes are walked). Restore replays the array through
+ * sequential adoption (each center tile APPENDS after the ones before it), so
+ * array order IS strip order — no `before` stamping needed; a stale `before`
+ * naming an absent pane falls back to append anyway (see insertAtGroup). Tiles
+ * not yet adopted sort after placed ones, stably. Returns `null` when nothing
+ * moves so callers can skip a needless persist. */
+export function orderTilesByTree(
+ tree: LayoutNode | null,
+ tiles: readonly T[]
+): null | T[] {
+ if (!tree || tiles.length < 2) {
+ return null
+ }
+
+ const order: string[] = []
+
+ const walk = (node: LayoutNode) => {
+ if (node.type === 'group') {
+ for (const id of node.panes) {
+ if (id.startsWith(TILE_PANE_PREFIX)) {
+ order.push(id.slice(TILE_PANE_PREFIX.length))
+ }
+ }
+
+ return
+ }
+
+ node.children.forEach(walk)
+ }
+
+ walk(tree)
+
+ const rank = new Map(order.map((id, i) => [id, i]))
+
+ const next = [...tiles].sort(
+ (a, b) => (rank.get(a.storedSessionId) ?? Infinity) - (rank.get(b.storedSessionId) ?? Infinity)
+ )
+
+ return next.some((t, i) => t !== tiles[i]) ? next : null
+}
+
+function syncTileStripOrder() {
+ const next = orderTilesByTree($layoutTree.get(), $sessionTiles.get())
+
+ if (next) {
+ saveTiles(next)
+ }
+}
+
/** Open a tile for a stored session, or MOVE an existing one to the new dock
* (`dir`; `center` = stack into the anchor's zone, `before` = strip slot). The
* move path is what lets a tile's own TAB be dragged like a sidebar row — drop
@@ -427,6 +496,8 @@ export function openSessionTile(
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
saveTiles([...tiles, { anchor, before, dir, storedSessionId }])
+ // Adoption is async via the registry — order sync runs after the move path
+ // below; a brand-new tile's strip slot is already in `before`.
return
}
@@ -438,6 +509,8 @@ export function openSessionTile(
if (target) {
moveTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`, { before: before ?? null, groupId: target, pos: dir })
+ patchSessionTile(storedSessionId, { anchor, before: before ?? undefined, dir })
+ syncTileStripOrder()
}
}
@@ -532,8 +605,6 @@ export function reopenLastClosedTile(): void {
// timer / model) reads these instead of the primary-only atoms.
// ---------------------------------------------------------------------------
-const TILE_PANE_PREFIX = 'session-tile:'
-
/** Stored id of the focused session (the interacted zone's tile, else the
* primary's selection). Null on a fresh draft. */
export const $focusedStoredSessionId = computed(
@@ -563,12 +634,20 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates]
runtimeId ? states[runtimeId] : undefined
)
-// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus
-// home to the workspace — a previously-clicked tile must not keep owning the
-// titlebar/statusbar readouts for a session switch it had no part in. It also
-// FRONTS the workspace tab: the resumed chat loads in the workspace pane, so a
-// zone parked on a tile tab must switch back or the click looks dead.
-$selectedStoredSessionId.listen(() => {
+/** A PRIMARY navigation (sidebar resume, route change, new chat) homes focus to
+ * the workspace — UNLESS the selected id is already an open TILE, where
+ * `focusOpenSession` owns the move and homing would yank every stacked tile
+ * behind the workspace (A+B "disappear" when switching to C). */
+export const selectionHomesToWorkspace = (selected: null | string, tiles: readonly SessionTile[]): boolean =>
+ !(selected && tiles.some(t => t.storedSessionId === selected))
+
+// Homing also FRONTS the workspace tab: the resumed chat loads in the workspace
+// pane, so a zone parked on a tile tab must switch back or the click looks dead.
+$selectedStoredSessionId.listen(selected => {
+ if (!selectionHomesToWorkspace(selected, $sessionTiles.get())) {
+ return
+ }
+
noteActiveTreeGroup(null)
revealTreePane('workspace')
})