Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/desktop/src/app/chat/composer/controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { $hudMode } from '@/store/hud'
import { $pickerStyle, DEFAULT_PICKER_STYLE, PICKER_STYLES } from '@/store/picker-style'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'

import { ComposerControls } from './controls'
Expand Down Expand Up @@ -58,6 +59,7 @@ async function expectShortcutTooltip(label: string, shortcut: string) {

afterEach(() => {
cleanup()
$pickerStyle.set(DEFAULT_PICKER_STYLE)
$hudMode.set(false)
})

Expand Down Expand Up @@ -223,3 +225,14 @@ describe('wake-word ear visibility', () => {
expect((ear as HTMLButtonElement).disabled).toBe(true)
})
})

// The preference controls visibility; compact layout still has the final say.
it.each(PICKER_STYLES)('keeps reasoning reachable in %s and respects compact layout', style => {
$pickerStyle.set(style)
const model = { ...state.model, supportsReasoning: true, reasoningMenuContent: <div>Options</div> }
const rendered = renderControls({ state: { ...state, model } })
expect(Boolean(screen.queryByTestId('reasoning-pill'))).toBe(style === 'split' || style === 'separated')
rendered.unmount()
renderControls({ state: { ...state, model }, compactModelPill: true })
expect(screen.queryByTestId('reasoning-pill')).toBeNull()
})
6 changes: 5 additions & 1 deletion apps/desktop/src/app/chat/composer/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { Ear, EarOff, iconSize, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
import { $pickerStyle, pickerBehavior } from '@/store/picker-style'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'

import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
Expand Down Expand Up @@ -71,6 +72,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const behavior = pickerBehavior(useStore($pickerStyle))
const hudMode = useStore($hudMode)

if (conversation.active) {
Expand Down Expand Up @@ -119,7 +121,9 @@ export function ComposerControls({
{hideModelPill ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{compactModelPill ? null : <ReasoningPill disabled={disabled} model={state.model} />}
{compactModelPill || !behavior.separateReasoningPill ? null : (
<ReasoningPill disabled={disabled} model={state.model} />
)}
</>
)}
{voiceControls}
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/app/chat/composer/model-pill.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_REASONING_EFFORT } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'

Expand All @@ -14,7 +15,8 @@ import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelPillLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import { $pickerStyle, pickerBehavior } from '@/store/picker-style'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'

import { onComposerModelMenuRequest } from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
Expand Down Expand Up @@ -56,6 +58,16 @@ export function ModelPill({
const viewProvider = useStore(view.$provider)
const currentModel = model.model || viewModel
const currentProvider = model.provider || viewProvider

const behavior = pickerBehavior(useStore($pickerStyle))
const reasoningEffort = useStore(view.$reasoningEffort)
const defaultEffort = useStore($defaultReasoningEffort)

const effort =
behavior.effortInModelLabel && model.supportsReasoning !== false
? reasoningEffort || defaultEffort || DEFAULT_REASONING_EFFORT
: undefined

const fastMode = useStore(view.$fast)
const modelSource = useStore($currentModelSource)
const runtimeId = useStore(view.$runtimeId)
Expand Down Expand Up @@ -129,7 +141,7 @@ export function ModelPill({
) : (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelPillLabel(currentModel, { fastMode })}</span>
<span className="truncate">{formatModelPillLabel(currentModel, { fastMode, effort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/app/contrib/hooks/use-gateway-scope-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react'

/** Initial discovery and reconnect preserve a saved pick; a new owner reseeds it. */
export function useGatewayScopeRefresh(
connectionId: string | null,
profile: string,
refresh: (force: boolean) => void
): void {
const previous = useRef({ connectionId, profile })

// eslint-disable-next-line no-restricted-syntax -- track the last resolved gateway identity across reconnects
useEffect(() => {
const last = previous.current
const resolvedId = connectionId ?? last.connectionId

if (resolvedId === last.connectionId && profile === last.profile) {
return
}

const changedOwner = profile !== last.profile || (last.connectionId !== null && resolvedId !== last.connectionId)
previous.current = { connectionId: resolvedId, profile }
// The first descriptor arriving is hydration, not a user changing sources.
// Keep config/model refreshes non-forced so their saved-selection guards run.
refresh(changedOwner)
}, [connectionId, profile, refresh])
}
22 changes: 5 additions & 17 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ import {
useBackgroundSync
} from './hooks/use-background-sync'
import { useDesktopIntegrations } from './hooks/use-desktop-integrations'
import { useGatewayScopeRefresh } from './hooks/use-gateway-scope-refresh'
import { usePetBridge } from './hooks/use-pet-bridge'
import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
Expand Down Expand Up @@ -632,25 +633,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// Swapping the live gateway to another source or profile must re-pull that
// source's model/config/profile state. Two sources commonly both expose a
// `default` profile, so profile alone is not a sufficient identity.
const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}`
const lastGatewayScopeRef = useRef(gatewayScope)

// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (gatewayScope === lastGatewayScopeRef.current) {
return
}

lastGatewayScopeRef.current = gatewayScope
// Force: the new source/profile pair has its own defaults, so reseed the
// selector even if the composer already shows values from the previous
// backend. These refreshes carry intent tokens so an in-flight picker
// click still wins.
void refreshCurrentModel(true)
void refreshHermesConfig(true)
useGatewayScopeRefresh(activeConnectionId, activeGatewayProfile, force => {
void refreshCurrentModel(force)
void refreshHermesConfig(force)
void refreshActiveProfile()
resetProjectTreeState()
}, [gatewayScope, refreshCurrentModel, refreshHermesConfig])
})

// New session anchored to a workspace. Seeds cwd + branch from the clicked
// workspace; an explicit worktree path also drills the sidebar into that
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { useGatewayScopeRefresh } from '@/app/contrib/hooks/use-gateway-scope-refresh'
import { getGlobalModelInfo } from '@/hermes'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { $activeGatewayProfile } from '@/store/profile'
Expand Down Expand Up @@ -560,6 +561,57 @@ describe('useModelControls', () => {
expect($currentModel.get()).toBe('openai/gpt-5.5')
})

it('restores a saved manual model on first connection and reconnect, but reseeds a different source', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'default-model', provider: 'default-provider' })
setCurrentModel('saved-model')
setCurrentProvider('saved-provider')
setCurrentModelSource('manual')
const queryClient = new QueryClient()
const requestGateway = vi.fn()

const { rerender } = renderHook(
({ connectionId }: { connectionId: string | null }) => {
const controls = useModelControls({ queryClient, requestGateway })
useGatewayScopeRefresh(connectionId, 'default', controls.refreshCurrentModel)
},
{ initialProps: { connectionId: null as string | null } }
)

await act(async () => rerender({ connectionId: 'local' }))
expect($currentModel.get()).toBe('saved-model')
expect(getCurrentModelSource()).toBe('manual')
await act(async () => rerender({ connectionId: null }))
await act(async () => rerender({ connectionId: 'local' }))
expect($currentModel.get()).toBe('saved-model')

await act(async () => rerender({ connectionId: 'remote' }))
expect($currentModel.get()).toBe('default-model')
expect(getCurrentModelSource()).toBe('default')
})

it('reseeds on an actual profile change even on legacy connections without an id', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'target-model', provider: 'target-provider' })
setCurrentModel('saved-model')
setCurrentModelSource('manual')
const queryClient = new QueryClient()
const requestGateway = vi.fn()

const { rerender } = renderHook(
({ profile }) => {
const controls = useModelControls({ queryClient, requestGateway })
useGatewayScopeRefresh(null, profile, controls.refreshCurrentModel)
},
{ initialProps: { profile: 'default' } }
)

await act(async () => {
$activeGatewayProfile.set('research')
rerender({ profile: 'research' })
})
expect($currentModel.get()).toBe('target-model')
expect(getGlobalModelInfo).toHaveBeenCalledWith('research')
})

it('reads a forced profile reseed from that concrete profile', async () => {
$activeGatewayProfile.set('fred-work')
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'local/model', provider: 'custom:local' })
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/app/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $introSplash, setIntroSplash } from '@/store/intro-splash'
import { notifyError } from '@/store/notifications'
import { $pickerStyle, type PickerStyle, setPickerStyle } from '@/store/picker-style'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled'
import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure'
Expand Down Expand Up @@ -410,6 +411,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) {
const hideCodeDiffs = useStore($hideCodeDiffs)
const hideThreadTimeline = useStore($hideThreadTimeline)
const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault)
const pickerStyle = useStore($pickerStyle)
const sessionListDensity = useStore($sessionListDensity)
const tabStripDefault = useStore($tabStripDefault)
const titlebarAppActionsSide = useStore($titlebarAppActionsSide)
Expand Down Expand Up @@ -495,6 +497,13 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) {
{ id: 'technical', label: a.technical }
] as const

const pickerStyleOptions: { id: PickerStyle; label: string }[] = [
{ id: 'classic', label: a.pickerStyleClassic },
{ id: 'split', label: a.pickerStyleSplit },
{ id: 'unified', label: a.pickerStyleUnified },
{ id: 'separated', label: a.pickerStyleSeparated }
]

const sessionDensityOptions = [
{ id: 'compact', label: a.sessionDensityCompact },
{ id: 'comfortable', label: a.sessionDensityComfortable },
Expand Down Expand Up @@ -691,6 +700,23 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) {
/>
)}

{show('window-layout') && (
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setPickerStyle(id)
}}
options={pickerStyleOptions}
value={pickerStyle}
/>
}
description={a.pickerStyleDesc}
title={a.pickerStyleTitle}
/>
)}

{show('window-layout') && (
<ListRow
action={
Expand Down
46 changes: 44 additions & 2 deletions apps/desktop/src/app/shell/model-catalog-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
setModelVisibilityOpen,
setVisibleModels
} from '@/store/model-visibility'
import { $pickerStyle, DEFAULT_PICKER_STYLE } from '@/store/picker-style'
import type { LocalRuntimeJob } from '@/types/hermes'

import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
Expand Down Expand Up @@ -40,6 +41,7 @@ vi.mock('@/hermes', () => ({
}))

beforeEach(() => {
$pickerStyle.set(DEFAULT_PICKER_STYLE)
$visibleModels.set(null)
$localRuntimeJobs.set([])
// These suites exercise the local-models rows, which ship behind --local.
Expand All @@ -60,15 +62,16 @@ afterEach(() => {

// A minimal controller — these tests are about the CATALOG's own behaviour
// (what it lists, what it offers), not about what any host does with a pick.
function renderMenu() {
function renderMenu(overrides: Partial<ModelMenuController> = {}) {
const select = vi.fn()

const controller: ModelMenuController = {
applyPreset: vi.fn(),
current: { effort: '', fast: false, model: '', provider: '' },
presetFor: () => ({}),
select,
setOptions: vi.fn()
setOptions: vi.fn(),
...overrides
}

const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
Expand Down Expand Up @@ -207,3 +210,42 @@ describe('in-flight local downloads', () => {
expect(screen.queryByText('Local')).toBeNull()
})
})

describe('picker style', () => {
it('separated keeps plain model rows selectable without a submenu', async () => {
$pickerStyle.set('separated')
const select = renderMenu()
const row = (await screen.findByText(/Gemini 3\.1 Pro/i)).closest('[role="menuitem"]')!
expect(row.hasAttribute('aria-haspopup')).toBe(false)
fireEvent.keyDown(row, { key: 'ArrowRight' })
expect(screen.queryByRole('menuitemradio')).toBeNull()
fireEvent.click(row)
await waitFor(() => expect(select).toHaveBeenCalledWith('gemini-3.1-pro', 'google'))
})

it.each([true, false])('unified applies effort only after model selection succeeds (accepted=%s)', async accepted => {
$pickerStyle.set('unified')
const select = vi.fn().mockResolvedValue(accepted)
const applyPreset = vi.fn()
const setOptions = vi.fn()
renderMenu({ select, applyPreset, setOptions })
const row = (await screen.findByText(/Gemini 3\.1 Pro/i)).closest('[role="menuitem"]')!
fireEvent.keyDown(row, { key: 'ArrowRight' })
const high = await screen.findByRole('menuitemradio', { name: /^High$/ })
fireEvent.click(high)
await waitFor(() => expect(select).toHaveBeenCalledWith('gemini-3.1-pro', 'google'))

if (accepted) {
await waitFor(() =>
expect(applyPreset).toHaveBeenCalledWith(
expect.objectContaining({ effort: 'high' }),
expect.objectContaining({ model: 'gemini-3.1-pro', provider: 'google' })
)
)
} else {
expect(applyPreset).not.toHaveBeenCalled()
}

expect(setOptions).not.toHaveBeenCalled()
})
})
Loading