mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(statusbar): right-click to choose what the bar shows
The status bar shipped every affordance it had, so approvals, the terminal toggle, agents, cron and webhooks sat there permanently for users who never touched them. Those five now start hidden and the bar owns a context menu that turns them back on, persisted per install. Items opt in by naming themselves with `toggleLabel`, so a plugin contribution that doesn't opt in always shows; the system icon and the version/update pills are listed but locked on, since hiding the way back into settings strands the user. Preferences store the hidden set rather than the visible one, so an item added to the bar in a later version appears for existing users instead of staying silently off.
This commit is contained in:
parent
0f7492f43a
commit
c5336b472e
10 changed files with 314 additions and 32 deletions
|
|
@ -245,8 +245,12 @@ export function useStatusbarItems({
|
|||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-client',
|
||||
label,
|
||||
// Update state is not a preference: hiding it is how a user misses that
|
||||
// their client is behind. Listed in the menu, but locked on.
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
toggleLabel: copy.toggleVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
|
|
@ -295,8 +299,10 @@ export function useStatusbarItems({
|
|||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
lockedVisible: true,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
toggleLabel: copy.toggleBackendVersion,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
|
|
@ -346,8 +352,12 @@ export function useStatusbarItems({
|
|||
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
// The system icon: the way into every other surface, including the
|
||||
// settings that would bring a hidden item back. Never hideable.
|
||||
lockedVisible: true,
|
||||
onSelect: toggleCommandCenter,
|
||||
title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
|
||||
toggleLabel: copy.toggleCommandCenter,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
|
|
@ -365,6 +375,7 @@ export function useStatusbarItems({
|
|||
menuClassName: 'w-72',
|
||||
menuContent: gatewayMenuContent,
|
||||
title: inferenceStatus?.reason || copy.gatewayTitle,
|
||||
toggleLabel: copy.gateway,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
|
|
@ -398,6 +409,7 @@ export function useStatusbarItems({
|
|||
]
|
||||
: undefined,
|
||||
title: currentCwd || undefined,
|
||||
toggleLabel: copy.toggleWorkspace,
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
|
|
@ -423,6 +435,7 @@ export function useStatusbarItems({
|
|||
label: copy.agents,
|
||||
onSelect: openAgents,
|
||||
title: agentsOpen ? copy.closeAgents : copy.openAgents,
|
||||
toggleLabel: copy.agents,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
|
|
@ -431,6 +444,7 @@ export function useStatusbarItems({
|
|||
label: copy.cron,
|
||||
title: copy.openCron,
|
||||
to: CRON_ROUTE,
|
||||
toggleLabel: copy.cron,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
|
|
@ -439,6 +453,7 @@ export function useStatusbarItems({
|
|||
label: copy.webhooks,
|
||||
title: copy.openWebhooks,
|
||||
to: WEBHOOKS_ROUTE,
|
||||
toggleLabel: copy.webhooks,
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
|
|
@ -504,7 +519,8 @@ export function useStatusbarItems({
|
|||
},
|
||||
{
|
||||
...approvalModeItem,
|
||||
hidden: gatewayState !== 'open'
|
||||
hidden: gatewayState !== 'open',
|
||||
toggleLabel: copy.toggleApprovalMode
|
||||
},
|
||||
{
|
||||
actionId: 'view.showTerminal',
|
||||
|
|
@ -514,6 +530,7 @@ export function useStatusbarItems({
|
|||
id: 'terminal',
|
||||
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
|
||||
toggleLabel: copy.toggleTerminal,
|
||||
variant: 'action'
|
||||
},
|
||||
clientVersionItem,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { type ComponentProps, memo, type ReactNode, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ComponentProps, memo, type ReactNode, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
|
||||
|
|
@ -47,6 +58,14 @@ export interface StatusbarItem {
|
|||
title?: string
|
||||
to?: string
|
||||
variant?: 'action' | 'link' | 'menu' | 'text'
|
||||
/** Plain-text name for the bar's right-click show/hide menu. An item without
|
||||
* one is never listed there and always shows — the safe default for plugin
|
||||
* contributions that don't opt in. */
|
||||
toggleLabel?: string
|
||||
/** Listed in the menu but not switchable: the bar's own affordances (command
|
||||
* center, update/version pills) would strand the user if they could be
|
||||
* hidden from the surface that hides them. */
|
||||
lockedVisible?: boolean
|
||||
}
|
||||
|
||||
export interface StatusbarSelectModifiers {
|
||||
|
|
@ -63,35 +82,98 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
|||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hiddenIds = useStore($statusbarHiddenIds)
|
||||
|
||||
const visible = (item: StatusbarItem) =>
|
||||
!item.hidden && (item.lockedVisible || !item.toggleLabel || !hiddenIds.includes(item.id))
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
data-slot="statusbar"
|
||||
{...props}
|
||||
>
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
data-slot="statusbar"
|
||||
{...props}
|
||||
>
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems.filter(visible).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items.filter(visible).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</ContextMenuTrigger>
|
||||
<StatusbarVisibilityMenu hiddenIds={hiddenIds} items={items} leftItems={leftItems} />
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right-click the bar to choose what it shows. Lists every item that named
|
||||
* itself with `toggleLabel`, in bar order (left cluster then right), so the
|
||||
* menu reads like the surface it edits. */
|
||||
function StatusbarVisibilityMenu({
|
||||
hiddenIds,
|
||||
items,
|
||||
leftItems
|
||||
}: {
|
||||
hiddenIds: readonly string[]
|
||||
items: readonly StatusbarItem[]
|
||||
leftItems: readonly StatusbarItem[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.statusbar
|
||||
|
||||
// Deduped by id: an item can legitimately appear in both clusters across
|
||||
// renders (contributions move sides), and a repeated checkbox would let one
|
||||
// row's toggle silently contradict the other's.
|
||||
const toggles = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return [...leftItems, ...items].filter(item => {
|
||||
if (!item.toggleLabel || seen.has(item.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seen.add(item.id)
|
||||
|
||||
return true
|
||||
})
|
||||
}, [items, leftItems])
|
||||
|
||||
if (toggles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuLabel>{copy.customizeTitle}</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{toggles.map(item => (
|
||||
<ContextMenuCheckboxItem
|
||||
checked={item.lockedVisible || !hiddenIds.includes(item.id)}
|
||||
disabled={item.lockedVisible}
|
||||
key={item.id}
|
||||
onCheckedChange={checked => setStatusbarItemVisible(item.id, checked)}
|
||||
// Radix closes the menu on select; keep it open so several items can
|
||||
// be toggled in one pass (this is a preferences surface, not a
|
||||
// command list).
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">{item.toggleLabel}</span>
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
99
apps/desktop/src/app/shell/statusbar-visibility.test.tsx
Normal file
99
apps/desktop/src/app/shell/statusbar-visibility.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import { $statusbarHiddenIds, STATUSBAR_HIDDEN_BY_DEFAULT } from '@/store/statusbar-prefs'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
|
||||
})
|
||||
|
||||
const item = (id: string, label: string, extra: Partial<StatusbarItem> = {}): StatusbarItem => ({
|
||||
id,
|
||||
label,
|
||||
toggleLabel: label,
|
||||
variant: 'action',
|
||||
...extra
|
||||
})
|
||||
|
||||
function bar(items: StatusbarItem[]) {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<StatusbarControls items={items} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
return screen.getByRole('contentinfo')
|
||||
}
|
||||
|
||||
/** Radix opens a ContextMenu on contextmenu after a pointerdown positions it. */
|
||||
function openContextMenu(target: HTMLElement) {
|
||||
fireEvent.pointerDown(target, { button: 2, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.contextMenu(target, { button: 2 })
|
||||
}
|
||||
|
||||
describe('statusbar item visibility', () => {
|
||||
it('hides the route/toggle items out of the box and keeps status items', () => {
|
||||
bar([
|
||||
item('cron', 'Cron'),
|
||||
item('webhooks', 'Webhooks'),
|
||||
item('agents', 'Agents'),
|
||||
item('terminal', 'Terminal'),
|
||||
item('approval-mode', 'Approvals'),
|
||||
item('gateway-health', 'Gateway')
|
||||
])
|
||||
|
||||
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
|
||||
expect(screen.queryByText(label)).toBeNull()
|
||||
}
|
||||
|
||||
expect(screen.getByText('Gateway')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an item once the user enables it from the bar context menu', async () => {
|
||||
const statusbar = bar([item('cron', 'Cron'), item('gateway-health', 'Gateway')])
|
||||
|
||||
expect(screen.queryByText('Cron')).toBeNull()
|
||||
|
||||
openContextMenu(statusbar)
|
||||
|
||||
const row = await screen.findByRole('menuitemcheckbox', { name: 'Cron' })
|
||||
fireEvent.click(row)
|
||||
|
||||
expect($statusbarHiddenIds.get()).not.toContain('cron')
|
||||
expect(within(statusbar).getByText('Cron')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('never lets the user hide a locked item (system icon / update pill)', async () => {
|
||||
const statusbar = bar([item('command-center', 'Command Center', { lockedVisible: true })])
|
||||
|
||||
openContextMenu(statusbar)
|
||||
|
||||
const row = await screen.findByRole('menuitemcheckbox', { name: 'Command Center' })
|
||||
expect(row.getAttribute('data-disabled')).not.toBeNull()
|
||||
expect(row.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('leaves items that never opted into the menu alone', () => {
|
||||
$statusbarHiddenIds.set(['plugin-thing'])
|
||||
bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }])
|
||||
|
||||
expect(screen.getByText('Plugin thing')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -58,6 +58,30 @@ function ContextMenuItem({
|
|||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ContextMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
|
|
@ -142,6 +166,7 @@ function ContextMenuSubContent({
|
|||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
|
|
|
|||
|
|
@ -2381,6 +2381,13 @@ export const en: Translations = {
|
|||
gatewayOffline: 'offline',
|
||||
gatewayRestarting: 'restarting…',
|
||||
gatewayTitle: 'Hermes inference gateway status',
|
||||
customizeTitle: 'Show in status bar',
|
||||
toggleApprovalMode: 'Approvals',
|
||||
toggleBackendVersion: 'Backend version',
|
||||
toggleCommandCenter: 'Command Center',
|
||||
toggleTerminal: 'Terminal',
|
||||
toggleVersion: 'Version & updates',
|
||||
toggleWorkspace: 'Workspace',
|
||||
agents: 'Agents',
|
||||
closeAgents: 'Close agents',
|
||||
openAgents: 'Open agents',
|
||||
|
|
|
|||
|
|
@ -1990,6 +1990,13 @@ export interface Translations {
|
|||
gatewayOffline: string
|
||||
gatewayRestarting: string
|
||||
gatewayTitle: string
|
||||
customizeTitle: string
|
||||
toggleApprovalMode: string
|
||||
toggleBackendVersion: string
|
||||
toggleCommandCenter: string
|
||||
toggleTerminal: string
|
||||
toggleVersion: string
|
||||
toggleWorkspace: string
|
||||
agents: string
|
||||
closeAgents: string
|
||||
openAgents: string
|
||||
|
|
|
|||
|
|
@ -2558,6 +2558,13 @@ export const zh: Translations = {
|
|||
gatewayOffline: '离线',
|
||||
gatewayRestarting: '重启中…',
|
||||
gatewayTitle: 'Hermes 推理网关状态',
|
||||
customizeTitle: '在状态栏中显示',
|
||||
toggleApprovalMode: '审批',
|
||||
toggleBackendVersion: '后端版本',
|
||||
toggleCommandCenter: '命令中心',
|
||||
toggleTerminal: '终端',
|
||||
toggleVersion: '版本与更新',
|
||||
toggleWorkspace: '工作区',
|
||||
agents: '代理',
|
||||
closeAgents: '关闭代理',
|
||||
openAgents: '打开代理',
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import {
|
|||
setCronSessions,
|
||||
setFreshDraftReady,
|
||||
setMessagingSessions,
|
||||
setSessionProfilesTruncated,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionProfilesTruncated
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { $stalledSessionIds } from '@/store/session-states'
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
setMessagingSessions,
|
||||
setMessagingTruncated,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionProfilesTruncated,
|
||||
setSessions,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { clearAllSessionStates } from '@/store/session-states'
|
||||
|
|
|
|||
38
apps/desktop/src/store/statusbar-prefs.ts
Normal file
38
apps/desktop/src/store/statusbar-prefs.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
|
||||
|
||||
// Items the bar hides until the user turns them on from its context menu. The
|
||||
// bar's job is to answer "is the backend healthy, where am I, what's it doing" —
|
||||
// route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval
|
||||
// pill are navigation, not status, so they start out of the way.
|
||||
export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
|
||||
'agents',
|
||||
'approval-mode',
|
||||
'cron',
|
||||
'terminal',
|
||||
'webhooks'
|
||||
]
|
||||
|
||||
// Stored as the explicit hidden set (not the visible one) so an item added to
|
||||
// the bar in a later version shows up for existing users instead of silently
|
||||
// staying off. An empty array is a real value — the user turned everything on —
|
||||
// so this uses a sanitizing json codec rather than Codecs.stringArray, which
|
||||
// drops the key when empty and would resurrect the defaults on next launch.
|
||||
export const $statusbarHiddenIds = persistentAtom<string[]>(
|
||||
STATUSBAR_HIDDEN_STORAGE_KEY,
|
||||
[...STATUSBAR_HIDDEN_BY_DEFAULT],
|
||||
Codecs.json<string[]>(value =>
|
||||
Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
|
||||
)
|
||||
)
|
||||
|
||||
export function setStatusbarItemVisible(id: string, visible: boolean) {
|
||||
const hidden = $statusbarHiddenIds.get()
|
||||
|
||||
if (visible === !hidden.includes(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
$statusbarHiddenIds.set(visible ? hidden.filter(entry => entry !== id) : [...hidden, id])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue