fix(desktop): scope multi-pane model UI and stabilize tile chrome (#67855)

* fix(desktop): scope multi-pane model UI and stabilize tile chrome

Composer model controls were still keyed off the primary session globals, so every tile showed the same model and a busy primary blocked switches in idle panes. Bind the pill/menu/select path to SessionView, force lone session-tile headers (incl. after tab cycle), and persist strip order so add/remove/switch stops scrambling adjacent panes.

* fix(desktop): scope preset effort/fast writes per surface, simplify tile order sync

A tile's model pick still pushed effort/fast onto the primary composer globals via applyModelPreset — scope it to the surface (primary → globals, tile → its session slice). Tile order persistence drops the before-stamping walk for a plain sort by tree encounter order; restore replays the array sequentially so array order is strip order.

* test(desktop): cover tile strip-order + selection-home; fix stale docs

Extract syncTileStripOrder's sort into a pure `orderTilesByTree` and the
selection listener's guard into `selectionHomesToWorkspace` (same shape as
the PR's lone-header extraction), then unit-test both — the two store
behaviors that shipped without coverage. Correct the `anchor`/`before` docs
(now persisted, not in-memory) and note that a tile's effort/fast edit still
writes the shared per-model preset even though the session write is scoped.

* fix(desktop): drop forbidden import() type annotations in model tests

`importOriginal<typeof import('…')>()` trips consistent-type-imports (error)
and reddens the desktop lint job. Switch to the repo's accepted top-level
`import type * as X` + `typeof X` form, matching skills/index.test.tsx.
This commit is contained in:
brooklyn! 2026-07-20 00:11:36 -05:00 committed by GitHub
parent e702a45b5d
commit 3aeded6e32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 508 additions and 114 deletions

View file

@ -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(<ModelPill disabled={false} model={modelState()} />)
render(<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />)
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(<ModelPill disabled={false} model={modelState()} />)
const { unmount } = render(
<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />
)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
render(
<ModelPill
disabled={false}
model={modelState({ model: 'deepseek/deepseek-v4-flash', modelMenuContent: <div /> })}
/>
)
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(
<SessionViewProvider value={tileView}>
<ModelPill
disabled={false}
model={modelState({ model: 'tile/claude-sonnet', provider: 'anthropic', modelMenuContent: <div /> })}
/>
</SessionViewProvider>
)
expect(screen.getByText('Sonnet · High')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})

View file

@ -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

View file

@ -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 ? (
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
onSelectModel={selectModel}
requestGateway={requestGateway}
/>
) : null,
[gatewayOpen, gatewayRef, requestGateway, selectModel]
)
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
<ChatView
gateway={gatewayRef.current}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}

View file

@ -7,8 +7,10 @@ import {
$awaitingResponse,
$busy,
$currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
@ -38,6 +40,8 @@ export interface SessionView {
$cwd: ReadableAtom<string>
$model: ReadableAtom<string>
$provider: ReadableAtom<string>
$fast: ReadableAtom<boolean>
$reasoningEffort: ReadableAtom<string>
}
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
}

View file

@ -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'] })
}}

View file

@ -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<typeof ModelPickerDialog>['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}
/>
)
}

View file

@ -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<T>() {
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setApiRequestProfile: vi.fn(),
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
}))
vi.mock('@/store/session-states', async importOriginal => {
const actual = await importOriginal<typeof SessionStates>()
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(<Harness onReady={value => (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')
})
})

View file

@ -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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
@ -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<ModelOptionsResponse>(['model-options', $activeSessionId.get() || 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(['model-options', sessionId || 'global'], patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['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<boolean> => {
// 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

View file

@ -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<typeof HermesApi>()
return { ...actual, setApiRequestProfile: vi.fn() }
})
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()

View file

@ -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)
}

View file

@ -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 })
})
})

View file

@ -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<boolean> | void
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@ -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
}
)
}

View file

@ -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)
})
})

View file

@ -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])
}

View file

@ -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.

View file

@ -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
}

View file

@ -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<void> {
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) {

View file

@ -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)
})
})

View file

@ -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<SessionTile, 'dir' | 'storedSessionId'>
/** 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<SessionTile, 'anchor' | 'before' | 'dir' | 'storedSessionId'>
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<T extends { storedSessionId: string }>(
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')
})