mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
feat(desktop): move model selector from statusbar to composer
Relocate the model pill to the composer, left of the mic. A new ModelPill reuses the live ModelMenuPanel dropdown verbatim (single click target) and the formatModelStatusLabel "Model · Fast Med" label, anchored to its right edge so the menu doesn't drift with model-name length. modelMenuContent now flows to ChatView instead of useStatusbarItems, and the status-bar model-summary item is removed; the pill subscribes to the model atoms directly and falls back to the full picker when the gateway is closed.
This commit is contained in:
parent
c6b0eb4de0
commit
c92a95a130
6 changed files with 83 additions and 52 deletions
|
|
@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
|
|
@ -81,6 +82,7 @@ export function ComposerControls({
|
|||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={steerTip}>
|
||||
|
|
|
|||
72
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
72
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PILL = cn(
|
||||
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ModelPill({ disabled, model }: { disabled: boolean; 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 label = (
|
||||
<>
|
||||
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</>
|
||||
)
|
||||
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={PILL}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
{model.modelMenuContent}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
|
|
@ -22,6 +24,8 @@ export interface ChatBarState {
|
|||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
|
||||
modelMenuContent?: ReactNode
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading'
|
|||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
modelMenuContent?: React.ReactNode
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => Promise<void> | void
|
||||
|
|
@ -250,6 +251,7 @@ function ChatRuntimeBoundary({
|
|||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
modelMenuContent,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
|
|
@ -346,6 +348,7 @@ export function ChatView({
|
|||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
modelMenuContent,
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
|
|
@ -358,7 +361,7 @@ export function ChatView({
|
|||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
|
|
|
|||
|
|
@ -859,7 +859,6 @@ export function DesktopController() {
|
|||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
freshDraftReady,
|
||||
openCommandCenterSection,
|
||||
|
|
@ -981,6 +980,7 @@ export function DesktopController() {
|
|||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
|
|
@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
|
|||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Command,
|
||||
Hash,
|
||||
|
|
@ -19,7 +17,6 @@ import {
|
|||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -30,16 +27,11 @@ import {
|
|||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
$yoloActive,
|
||||
setModelPickerOpen,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
|
|
@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
|
|||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
freshDraftReady: boolean
|
||||
|
|
@ -83,7 +74,6 @@ export function useStatusbarItems({
|
|||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
freshDraftReady,
|
||||
|
|
@ -97,10 +87,6 @@ export function useStatusbarItems({
|
|||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
|
|
@ -416,37 +402,6 @@ export function useStatusbarItems({
|
|||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
id: 'model-summary',
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
||||
: copy.switchModel,
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
|
||||
: copy.openModelPicker,
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
hidden: !chatOpen,
|
||||
|
|
@ -465,11 +420,6 @@ export function useStatusbarItems({
|
|||
contextBar,
|
||||
contextUsage,
|
||||
copy,
|
||||
currentFastMode,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
terminalTakeover,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue