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