mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge upstream/main into fix/sidebar-icon-button-tooltips
This commit is contained in:
commit
91ac18c915
4 changed files with 153 additions and 97 deletions
|
|
@ -25,7 +25,7 @@ import {
|
|||
openProjectRename,
|
||||
revealPath,
|
||||
setActiveProject,
|
||||
updateProject
|
||||
setProjectAppearance
|
||||
} from '@/store/projects'
|
||||
|
||||
import type { SidebarProjectTree } from './workspace-groups'
|
||||
|
|
@ -111,6 +111,26 @@ export function ProjectMenu({
|
|||
}
|
||||
}
|
||||
|
||||
// Appearance writes route through the adopt-aware helper: an auto project is
|
||||
// materialized on its first change (its id then changes), so close the picker
|
||||
// when that happens to avoid a second write double-creating from a stale node.
|
||||
const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => {
|
||||
void setProjectAppearance(project, patch).then(adopted => {
|
||||
if (adopted) {
|
||||
setAppearanceOpen(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set color / pick an icon — shown for explicit projects and for auto ones
|
||||
// (where selecting adopts the repo as a real project so the look sticks).
|
||||
const appearanceItem = (
|
||||
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>{p.menuAppearance}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
<Tip label={p.menu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -147,16 +167,23 @@ export function ProjectMenu({
|
|||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
sideOffset={6}
|
||||
>
|
||||
{!project.isAuto && (
|
||||
{project.isAuto ? (
|
||||
// Inherited (auto) repos can still be themed — the change adopts the
|
||||
// repo as a real project. Rename / add-folder / set-active stay out
|
||||
// until then (they need the materialized record).
|
||||
project.path ? (
|
||||
<>
|
||||
{appearanceItem}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.menuRename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>{p.menuAppearance}</span>
|
||||
</DropdownMenuItem>
|
||||
{appearanceItem}
|
||||
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
|
||||
<Codicon name="new-folder" size="0.875rem" />
|
||||
<span>{p.menuAddFolder}</span>
|
||||
|
|
@ -200,7 +227,7 @@ export function ProjectMenu({
|
|||
<ColorSwatches
|
||||
clearIcon="circle-slash"
|
||||
clearLabel={p.noColor}
|
||||
onChange={color => void updateProject(project.id, { color })}
|
||||
onChange={color => applyAppearance({ color })}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
value={project.color ?? null}
|
||||
/>
|
||||
|
|
@ -215,7 +242,7 @@ export function ProjectMenu({
|
|||
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
|
||||
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
|
||||
onClick={() => applyAppearance({ icon: project.icon === name ? null : name })}
|
||||
style={project.icon === name && project.color ? { color: project.color } : undefined}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { coarseElapsed } from '@/lib/time'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById } from '@/store/session-color'
|
||||
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
|
|
@ -91,6 +92,9 @@ export function SidebarSessionRow({
|
|||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
// The session's resolved color (idle dot tint), read from the ONE shared map
|
||||
// the pane tabs also read — an O(1) lookup, never re-derived per render.
|
||||
const projectColor = useStore($sessionColorById)[session.id] ?? null
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
|
|
@ -242,11 +246,12 @@ export function SidebarSessionRow({
|
|||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
dotState={dotState}
|
||||
projectColor={projectColor}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
|
|
@ -276,11 +281,13 @@ type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'worki
|
|||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
dotState = 'idle',
|
||||
className
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
branchStem?: string
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
|
|
@ -289,7 +296,7 @@ function SessionRowLeadDot({
|
|||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot dotState={dotState} />
|
||||
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -350,9 +357,32 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
|
||||
function SidebarRowDot({
|
||||
dotState,
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
dotState: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// An idle session inherits its project's color (a quiet marker matching the
|
||||
// project row's own color dot). The active states (working / needs-input /
|
||||
// background / unread) own the dot and keep their semantic color, so the
|
||||
// inherited tint never competes with an attention cue.
|
||||
if (dotState === 'idle' && projectColor) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1 rounded-full', className)}
|
||||
style={{ backgroundColor: projectColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -888,86 +888,86 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||
return (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`aux-task-${meta.key}`} key={meta.key}>
|
||||
<ListRow
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{m.setToMain}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{m.change}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withActive(auxDraftProviderModels, auxDraft.model).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applying}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
size="sm"
|
||||
>
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{copy.label}
|
||||
<Pill>{copy.hint}</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{m.setToMain}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{m.change}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withActive(auxDraftProviderModels, auxDraft.model).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applying}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
size="sm"
|
||||
>
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{copy.label}
|
||||
<Pill>{copy.hint}</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ vi.mock('@/hermes', () => ({
|
|||
getToolsetModels: (name: string, provider?: string) => getToolsetModels(name, provider),
|
||||
selectToolsetModel: (name: string, model: string, provider?: string) => selectToolsetModel(name, model, provider),
|
||||
selectToolsetProvider: (name: string, provider: string, capability?: string) =>
|
||||
capability === undefined ? selectToolsetProvider(name, provider) : selectToolsetProvider(name, provider, capability),
|
||||
capability === undefined
|
||||
? selectToolsetProvider(name, provider)
|
||||
: selectToolsetProvider(name, provider, capability),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
deleteEnvVar: (key: string) => deleteEnvVar(key),
|
||||
revealEnvVar: (key: string) => revealEnvVar(key),
|
||||
|
|
@ -378,7 +380,6 @@ describe('ToolsetConfigPanel', () => {
|
|||
})
|
||||
})
|
||||
|
||||
|
||||
it('swaps the install hint for the installed one-liner when the provider is ready', async () => {
|
||||
// Server says the post_setup install is already satisfied (status ready) —
|
||||
// the "needs a one-time install" copy would contradict the Ready pill.
|
||||
|
|
@ -825,9 +826,7 @@ describe('ToolsetConfigPanel', () => {
|
|||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Manage in API Keys' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=keys&key=ELEVENLABS_API_KEY')
|
||||
)
|
||||
await waitFor(() => expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=keys&key=ELEVENLABS_API_KEY'))
|
||||
})
|
||||
|
||||
it('hides "Manage in API Keys" while the key is unset', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue