mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
* fix(desktop): unify dialog/overlay buttons on shared Button component
Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.
* feat(desktop): add dev-only dialog gallery for auditing overlays
A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.
* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)
The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.
* fix(desktop): stop overriding button icon size to size-4
Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.
* feat(desktop): add BrandMark, use it in the updates overlay hero
New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.
NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.
* feat(desktop): add real logo.png asset, render it white in BrandMark
logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.
* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding
Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.
* fix(desktop): bump updates-overlay BrandMark to size-16
* feat(desktop): use downscaled karb.webp in BrandMark
Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.
* feat(desktop): use nous-girl mark in BrandMark, invert in dark
Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.
* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)
The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.
* fix(desktop): give BrandMark an explicit white bg tile
* fix(desktop): use nous-girl.jpg directly in BrandMark
* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)
* style(desktop): bump nous light --theme-secondary to 14% blue
* fix(desktop): outline button is transparent, not chrome-filled
The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.
* fix(desktop): clean outline button — thin border, no shadow/fill
Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.
* fix(desktop): stop forcing tertiary bg on outline buttons
A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.
* style(desktop): unified overlay design system + restore #38631 flat-UI
Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.
- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers
* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs
- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
196 lines
7 KiB
TypeScript
196 lines
7 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { CopyButton } from '@/components/ui/copy-button'
|
|
import { useI18n } from '@/i18n'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
$notifications,
|
|
type AppNotification,
|
|
clearNotifications,
|
|
dismissNotification,
|
|
type NotificationKind
|
|
} from '@/store/notifications'
|
|
|
|
type ToneVariant = 'default' | 'destructive' | 'warning' | 'success'
|
|
|
|
const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; variant: ToneVariant }> = {
|
|
error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' },
|
|
warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' },
|
|
info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' },
|
|
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
|
|
}
|
|
|
|
const STACK_SURFACE = 'pointer-events-auto border border-(--stroke-nous) bg-popover/95 shadow-nous backdrop-blur-md'
|
|
|
|
export function NotificationStack() {
|
|
const notifications = useStore($notifications)
|
|
const { t } = useI18n()
|
|
const lastNotificationIdRef = useRef<string | null>(null)
|
|
const [expanded, setExpanded] = useState(false)
|
|
const copy = t.notifications
|
|
|
|
useEffect(() => {
|
|
if (notifications.length <= 1) {
|
|
setExpanded(false)
|
|
}
|
|
}, [notifications.length])
|
|
|
|
useEffect(() => {
|
|
const latest = notifications[0]
|
|
|
|
if (!latest || latest.id === lastNotificationIdRef.current) {
|
|
return
|
|
}
|
|
|
|
lastNotificationIdRef.current = latest.id
|
|
|
|
if (latest.kind === 'success') {
|
|
triggerHaptic('success')
|
|
} else if (latest.kind === 'error') {
|
|
triggerHaptic('error')
|
|
} else if (latest.kind === 'warning') {
|
|
triggerHaptic('warning')
|
|
}
|
|
}, [notifications])
|
|
|
|
if (notifications.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const [latest, ...olderNotifications] = notifications
|
|
const overflowCount = olderNotifications.length
|
|
|
|
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
|
|
// content z-[130]). Without the portal the stack lives inside the React root
|
|
// subtree, which any body-level dialog/overlay portal paints over — so a
|
|
// success toast fired while a dialog is open (or over an OverlayView page)
|
|
// was invisible. The titlebar-height var only exists inside the app shell
|
|
// scope, so fall back to its constant (34px) when mounted on <body>.
|
|
return createPortal(
|
|
<div
|
|
aria-label={copy.region}
|
|
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
|
role="region"
|
|
>
|
|
<NotificationItem notification={latest} />
|
|
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
|
|
{overflowCount > 0 && (
|
|
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
|
|
<Button className="-ml-2 font-medium" onClick={() => setExpanded(v => !v)} size="xs" type="button" variant="text">
|
|
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
|
|
</Button>
|
|
<Button className="-mr-2" onClick={clearNotifications} size="xs" type="button" variant="text">
|
|
{copy.clearAll}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|
|
|
|
function NotificationItem({ notification }: { notification: AppNotification }) {
|
|
const styles = tone[notification.kind]
|
|
const Icon = styles.icon
|
|
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
|
|
const { t } = useI18n()
|
|
const copy = t.notifications
|
|
|
|
return (
|
|
<Alert
|
|
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
|
className={cn(STACK_SURFACE, 'grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5')}
|
|
role={notification.kind === 'error' ? 'alert' : 'status'}
|
|
variant="default"
|
|
>
|
|
<Icon className={styles.iconClass} />
|
|
<div className="col-start-2 min-w-0">
|
|
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
|
<AlertDescription className="col-start-auto">
|
|
<p className="m-0">{notification.message}</p>
|
|
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
|
{notification.action && (
|
|
<Button
|
|
className="mt-1.5 bg-primary/15 font-medium text-primary hover:bg-primary/25 hover:text-primary"
|
|
onClick={() => {
|
|
notification.action?.onClick()
|
|
dismissNotification(notification.id)
|
|
}}
|
|
size="xs"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{notification.action.label}
|
|
</Button>
|
|
)}
|
|
</AlertDescription>
|
|
</div>
|
|
<Button
|
|
aria-label={copy.dismiss}
|
|
className="col-start-3 -mr-1 text-muted-foreground"
|
|
onClick={() => dismissNotification(notification.id)}
|
|
size="icon-xs"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Codicon name="close" size="0.875rem" />
|
|
</Button>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
function NotificationDetail({ detail }: { detail: string }) {
|
|
const { t } = useI18n()
|
|
const copy = t.notifications
|
|
|
|
return (
|
|
<details className="mt-2 text-xs text-muted-foreground">
|
|
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
|
|
<div className="mt-1 rounded-md bg-background/65 p-2">
|
|
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
|
{detail}
|
|
</pre>
|
|
<CopyButton
|
|
appearance="inline"
|
|
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
errorMessage={copy.copyDetailFailed}
|
|
iconClassName="size-3"
|
|
label={copy.copyDetail}
|
|
text={detail}
|
|
>
|
|
{copy.copyDetail}
|
|
</CopyButton>
|
|
</div>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
export function InlineNotice({
|
|
kind = 'info',
|
|
title,
|
|
children,
|
|
className
|
|
}: {
|
|
kind?: NotificationKind
|
|
title?: string
|
|
children: ReactNode
|
|
className?: string
|
|
}) {
|
|
const styles = tone[kind]
|
|
const Icon = styles.icon
|
|
|
|
return (
|
|
<Alert className={cn('min-w-0', className)} role={kind === 'error' ? 'alert' : 'status'} variant={styles.variant}>
|
|
<Icon />
|
|
{title && <AlertTitle>{title}</AlertTitle>}
|
|
<AlertDescription className={cn(!title && 'row-start-1')}>{children}</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|