feat(desktop): unified overlay design system, BrandMark & onboarding redesign (#40708)

* 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
This commit is contained in:
brooklyn!
2026-06-06 16:32:47 -05:00
committed by GitHub
parent c79e3fd0ba
commit f033b7dbfb
33 changed files with 1098 additions and 1144 deletions
@@ -38,17 +38,9 @@ export function UrlDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-5">
<DialogHeader className="flex-row items-center gap-3 sm:items-center">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div>
<DialogHeader>
<DialogTitle icon={Globe}>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -434,7 +434,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
{c.newCron}
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
<div className="divide-y divide-(--ui-stroke-tertiary)">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
+8 -12
View File
@@ -449,7 +449,7 @@ function PlatformDetail({
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
@@ -477,17 +477,13 @@ function PlatformDetail({
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
@@ -1,7 +1,6 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
@@ -12,6 +11,7 @@ interface OverlaySearchInputProps {
value: string
}
// Borderless underline search — matches the tools/skills page (PageSearchShell).
export function OverlaySearchInput({
containerClassName,
inputRef,
@@ -22,11 +22,7 @@ export function OverlaySearchInput({
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
containerClassName={containerClassName}
inputRef={inputRef}
loading={loading}
onChange={onChange}
+51 -96
View File
@@ -30,10 +30,8 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -41,30 +39,20 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps extends React.ComponentProps<'section'> {
interface ProfilesViewProps {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({
onClose,
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
export function ProfilesView({ onClose }: ProfilesViewProps) {
const { t } = useI18n()
const p = t.profiles
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
@@ -77,8 +65,6 @@ export function ProfilesView({
})
} catch (err) {
notifyError(err, p.failedLoad)
} finally {
setRefreshing(false)
}
}, [p])
@@ -88,24 +74,6 @@ export function ProfilesView({
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? p.refreshing : p.refresh,
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [p, refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@@ -172,64 +140,54 @@ export function ProfilesView({
return (
<OverlayView closeLabel={p.close} onClose={onClose}>
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">{p.title}</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? p.count(profiles.length) : ''}
</span>
</header>
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<Button
className="mb-1 w-full justify-start gap-2"
onClick={() => setCreateOpen(true)}
size="sm"
variant="text"
>
<Codicon name="add" />
{p.newProfile}
</Button>
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
)}
</OverlaySidebar>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Codicon name="add" />
{p.newProfile}
</Button>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</li>
)}
</ul>
</aside>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
@@ -261,7 +219,6 @@ export function ProfilesView({
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</OverlayView>
)
}
@@ -273,7 +230,7 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
return (
<button
className={cn(
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
onClick={onSelect}
@@ -368,7 +325,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<DetailRow label={p.modelLabel}>
{profile.model ? (
<>
@@ -475,9 +432,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
{p.loadingSoul}
</div>
<PageLoader className="min-h-44" label={p.loadingSoul} />
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@@ -92,9 +93,7 @@ export function AboutSettings() {
return (
<SettingsContent>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-8" />
</span>
<BrandMark className="size-16" />
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
@@ -10,7 +11,7 @@ import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { Pill, SectionHeading, SettingsContent } from './primitives'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -56,169 +57,105 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
{ id: 'product', label: a.product },
{ id: 'technical', label: a.technical }
] as const
return (
<SettingsContent>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
title={t.language.label}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
below={
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
}
description={a.themeDesc}
title={a.themeTitle}
wide
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={toolOptions}
value={toolViewMode}
/>
}
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
</div>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<LanguageSwitcher />
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.colorMode}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
</div>
<Pill>{t.settings.modeOptions[mode].label}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
const active = mode === id
const copy = t.settings.modeOptions[id]
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
</section>
</div>
</SettingsContent>
)
@@ -96,7 +96,7 @@ export function KeyField({
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
@@ -106,9 +106,10 @@ export function KeyField({
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
className="text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="inline"
type="button"
variant="text"
>
@@ -535,13 +535,13 @@ export function GatewaySettings() {
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
@@ -591,14 +591,14 @@ export function GatewaySettings() {
size="sm"
variant="text"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
@@ -607,7 +607,7 @@ export function GatewaySettings() {
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<FileText className="size-4" />
<FileText />
{g.openLogs}
</Button>
}
@@ -111,8 +111,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
className="text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
size="inline"
type="button"
variant="textStrong"
>
@@ -143,8 +144,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
className="py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
size="inline"
type="button"
variant="text"
>
@@ -111,13 +111,15 @@ export function GatewayMenuPanel({
</Tip>
))}
</ul>
<button
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
<Button
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
onClick={onOpenSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</button>
</Button>
</div>
)}
+4 -10
View File
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { useI18n } from '@/i18n'
@@ -56,7 +57,7 @@ export function KeybindPanel() {
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-[0_20px_48px_-24px_rgba(0,0,0,0.55)] duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
@@ -124,14 +125,10 @@ function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: ()
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<button
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.72rem] font-medium text-muted-foreground transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClick}
type="button"
>
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
<Codicon name={icon} size="0.8125rem" />
{label}
</button>
</Button>
)
}
@@ -152,8 +149,6 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
return (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
{/* Mirrors the reset button's footprint on the right so rows stay uniform. */}
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
@@ -211,7 +206,6 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
+34 -31
View File
@@ -15,6 +15,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -191,32 +192,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
))}
</>
) : undefined
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
@@ -236,21 +227,33 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleSkills.length === 0 ? (
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -276,7 +279,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleToolsets.length === 0 ? (
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : (
@@ -284,7 +287,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
+14 -22
View File
@@ -1,14 +1,16 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorState } from '@/components/ui/error-state'
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$updateApply,
@@ -119,7 +121,7 @@ function IdleView({
if (!status && checking) {
return (
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={u.checking} />
<CenteredStatus icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />} title={u.checking} />
)
}
@@ -131,7 +133,7 @@ function IdleView({
{u.tryAgain}
</Button>
}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
@@ -156,7 +158,7 @@ function IdleView({
</Button>
}
body={u.connectionRetry}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
icon={<ErrorIcon />}
title={u.checkFailedTitle}
/>
)
@@ -179,9 +181,7 @@ function IdleView({
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-7" />
</span>
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -209,13 +209,9 @@ function IdleView({
<Button className="font-semibold" onClick={onInstall} size="lg">
{u.updateNow}
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onLater}
type="button"
>
<Button className="font-medium" onClick={onLater} type="button" variant="text">
{u.maybeLater}
</button>
</Button>
</div>
{remaining > 0 && (
@@ -242,9 +238,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Terminal className="size-7" />
</span>
<Terminal className="size-8 text-primary" />
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -280,7 +274,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
{u.manualPickedUp}
</p>
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
{u.done}
</Button>
</div>
@@ -300,9 +294,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
return (
<div className="grid gap-5 px-6 pb-6 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Loader2 className="size-7 animate-spin" />
</span>
<Loader className="size-16" label={label} type="lemniscate-bloom" />
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -365,7 +357,7 @@ function CenteredStatus({
return (
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span>
{icon}
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}
@@ -264,14 +264,16 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{!typing && hasChoices && (
<div className="flex justify-end">
<button
className="bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
<Button
className="-mr-2"
disabled={!ready || submitting}
onClick={() => void respond('')}
size="xs"
type="button"
variant="text"
>
Skip
</button>
{copy.skip}
</Button>
</div>
)}
</div>
@@ -2,9 +2,11 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorIcon } from '@/components/ui/error-state'
import { LogView } from '@/components/ui/log-view'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
@@ -172,11 +174,9 @@ export function BootFailureOverlay() {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<div className="flex items-start gap-3 px-5 py-4">
<ErrorIcon className="mt-0.5" size="1.25rem" />
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? copy.remoteTitle : copy.title}
@@ -196,27 +196,27 @@ export function BootFailureOverlay() {
<div className="flex flex-wrap gap-2">
{remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
{label}
</Button>
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{copy.retry}
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
<FileText />
{copy.openLogs}
</Button>
</div>
@@ -227,18 +227,16 @@ export function BootFailureOverlay() {
{logs.length > 0 ? (
<div className="grid gap-2">
<button
className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground"
<Button
className="-ml-2 self-start font-medium"
onClick={() => setShowLogs(v => !v)}
size="xs"
type="button"
variant="text"
>
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
{logs.slice(-40).join('')}
</pre>
) : null}
</Button>
{showLogs ? <LogView className="max-h-48">{logs.slice(-40).join('')}</LogView> : null}
</div>
) : null}
</div>
@@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
// Fills the tile (no padding/radius); size via className (default size-14).
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)}
{...props}
>
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
</span>
)
}
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader } from '@/components/ui/loader'
import { LogView } from '@/components/ui/log-view'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@@ -350,7 +352,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{copy.unsupportedDesc(platformLabel)}
@@ -411,7 +413,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
{/* Header -- always visible, never scrolls */}
<div className="flex-shrink-0 p-8 pb-4">
<h2 className="text-2xl font-semibold tracking-tight">
@@ -444,8 +446,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
)}
{totalCount === 0 && state.active && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<div className="mb-4 flex items-center gap-2.5 text-sm text-muted-foreground">
<Loader className="size-5" type="lemniscate-bloom" />
<span>{copy.fetchingManifest}</span>
</div>
)}
@@ -474,53 +476,44 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</ol>
)}
<div className="border-t pt-3">
<button
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
<div className="pt-3">
<Button
className="-ml-2 text-muted-foreground hover:text-foreground"
onClick={() => setLogOpen(v => !v)}
size="xs"
type="button"
variant="ghost"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
<span className="ml-1 tabular-nums">
({copy.lines(state.log.length)})
</span>
</button>
</Button>
{logOpen && (
<div
className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}
>
<LogView className={cn('mt-2', failed ? 'max-h-96' : 'max-h-64')}>
{state.log.length === 0 ? (
<div className="text-muted-foreground">{copy.noOutput}</div>
<div>{copy.noOutput}</div>
) : (
<>
{state.log.map((entry, i) => (
<div
className={cn(
'whitespace-pre-wrap break-words',
entry.stream === 'stderr' && 'text-muted-foreground'
)}
key={i}
>
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<div className={cn(entry.stream === 'stderr' && 'text-muted-foreground/70')} key={i}>
{entry.stage ? <span className="text-muted-foreground/60">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
</div>
))}
<div ref={logEndRef} />
</>
)}
</div>
</LogView>
)}
</div>
</div>
{/* Active footer: let the user actually cancel a running install. */}
{state.active && !failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex-shrink-0 bg-card p-4">
<div className="flex items-center justify-end">
<Button
disabled={cancelling}
@@ -545,7 +538,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex-shrink-0 bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{copy.transcriptSaved}{' '}
@@ -5,7 +5,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import {
@@ -16,7 +18,6 @@ import {
ExternalLink,
KeyRound,
Loader2,
Sparkles,
Terminal
} from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
@@ -30,6 +31,8 @@ import {
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
DEFAULT_MANUAL_ONBOARDING_REASON,
DEFAULT_ONBOARDING_REASON,
dismissFirstRunOnboarding,
type OnboardingContext,
type OnboardingFlow,
@@ -183,6 +186,11 @@ const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
// Exit choreography, mirroring the gateway "connecting" overlay's timing:
// text-out (360ms: CONNECTED fades down, rest scrambles+fades) → hold (300ms)
// → surface-out (520ms, held back by [transition-delay:660ms]). Finalize after.
const ONBOARDING_EXIT_MS = 1180
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
@@ -198,6 +206,29 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
[]
)
// Cinematic exit on "Begin": dissolve the panel + overlay (revealing the chat
// behind), THEN finalize so the unmount lands after the fade — mirrors the
// connecting overlay's exit choreography instead of cutting instantly.
const [leaving, setLeaving] = useState(false)
const finalizeOnboarding = () => {
if (leaving) {
return
}
const reduce =
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) {
confirmOnboardingModel(ctx)
return
}
setLeaving(true)
window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS)
}
useEffect(() => {
if (enabled || onboarding.requested) {
void refreshOnboarding(ctx)
@@ -251,18 +282,52 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
const { flow } = onboarding
// Show the launch reason only when it's a meaningful, caller-supplied prompt —
// suppress the generic defaults (useless noise) and provider-setup errors
// (those are surfaced by FlowPanel, not as a banner).
const rawReason = onboarding.reason?.trim() || null
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
const reason =
rawReason &&
!isProviderSetupErrorMessage(rawReason) &&
rawReason !== DEFAULT_ONBOARDING_REASON &&
rawReason !== DEFAULT_MANUAL_ONBOARDING_REASON
? rawReason
: null
// In manual mode the app is already configured, so the flow is "ready"
// immediately — no runtime gate needed. Otherwise wait for the readiness
// check (configured === false) before showing the picker.
const ready = onboarding.manual || (enabled && onboarding.configured === false)
const showPicker = flow.status === 'idle' || flow.status === 'success'
// The final "you're in" screen drops the card chrome and floats centered on
// the surface — same bare, cinematic treatment as the connecting overlay.
const bare = ready && !showPicker && flow.status === 'confirming_model'
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background)">
<Header />
<div
className={cn(
'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
// On the bare confirm screen, hold the surface (text-out + hold) so the
// per-element exit plays before it dissolves.
bare && leaving ? '[transition-delay:660ms]' : '',
leaving ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<div
className={cn(
'relative w-full max-w-[45rem] transition-all duration-500 ease-out',
bare
? ''
: 'overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous',
// Bare confirm screen orchestrates its own per-element exit; the
// carded states use the simple lift/blur dissolve.
leaving && !bare
? '-translate-y-1 scale-[0.985] opacity-0 blur-[2px]'
: 'translate-y-0 scale-100 opacity-100 blur-0'
)}
>
{showPicker || !ready ? <Header /> : null}
{onboarding.manual ? (
<Button
aria-label={t.common.close}
@@ -276,16 +341,24 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
) : null}
<div className="grid gap-3 p-5">
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
{ready ? (
showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
)
) : (
<Preparing boot={boot} />
)}
</div>
</div>
</div>
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
// The launch reason is a prompt ("why am I seeing this"), not an error. Only
// rendered for meaningful caller-supplied reasons (defaults are filtered out
// upstream), so it never shows the generic "no provider configured" noise.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
@@ -327,18 +400,9 @@ function Header() {
const { t } = useI18n()
return (
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{t.onboarding.headerDesc}
</p>
</div>
</div>
<div className="bg-(--ui-chat-bubble-background) px-5 pt-5 pb-1">
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">{t.onboarding.headerDesc}</p>
</div>
)
}
@@ -416,27 +480,31 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
) : null}
</div>
{collapsible ? (
<button
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
<Button
className="mt-1 self-center font-medium"
onClick={() => setShowAll(persistShowAll(!showAll))}
size="xs"
type="button"
variant="text"
>
{showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</button>
</Button>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
{/* First run only: let the user defer the choice and land in the app.
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</button>
</Button>
</div>
</div>
)
@@ -449,13 +517,15 @@ function ChooseLaterLink() {
const { t } = useI18n()
return (
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
<Button
className="font-medium"
onClick={() => dismissFirstRunOnboarding()}
size="xs"
type="button"
variant="text"
>
{t.onboarding.chooseLater}
</button>
</Button>
)
}
@@ -509,15 +579,14 @@ function ConnectedTag() {
)
}
const PROVIDER_ROW_CLASS =
'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={onClick}
type="button"
>
<button className={PROVIDER_ROW_CLASS} onClick={onClick} type="button">
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
@@ -539,11 +608,7 @@ export function ProviderRow({
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onSelect(provider)}
type="button"
>
<button className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)} type="button">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
@@ -642,17 +707,19 @@ export function ApiKeyForm({
return (
<div className="grid gap-4">
{canGoBack ? (
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
<Button
className="-mt-1 self-start font-medium"
onClick={onBack}
size="xs"
type="button"
variant="text"
>
<ChevronLeft className="size-3" />
{t.onboarding.backToSignIn}
</button>
</Button>
) : null}
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
<div className="grid max-h-[42dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
{options.map(o => (
<button
className={cn(
@@ -704,7 +771,7 @@ export function ApiKeyForm({
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? <Loader2 className="animate-spin" /> : <KeyRound />}
{saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
</Button>
</div>
@@ -712,7 +779,17 @@ export function ApiKeyForm({
)
}
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
function FlowPanel({
ctx,
flow,
leaving,
onBegin
}: {
ctx: OnboardingContext
flow: OnboardingFlow
leaving: boolean
onBegin: () => void
}) {
const { t } = useI18n()
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
@@ -726,22 +803,20 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'success') {
return (
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4" />
{t.onboarding.connectedPicking(title)}
</div>
<DecodedLabel text={t.onboarding.connectedPicking(title)} />
)
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel ctx={ctx} flow={flow} />
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
}
if (flow.status === 'error') {
return (
<div className="grid gap-3">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{flow.message || t.onboarding.signInFailed}
<div className="flex items-center gap-1.5 text-sm text-destructive">
<ErrorIcon className="shrink-0" size="0.875rem" />
<span>{flow.message || t.onboarding.signInFailed}</span>
</div>
<div className="flex justify-end">
<Button onClick={cancelOnboardingFlow} variant="outline">
@@ -805,10 +880,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
}
>
<CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
{t.onboarding.signedIn}
</Button>
<Button onClick={() => void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}</Button>
</FlowFooter>
</Step>
)
@@ -821,7 +893,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
<DeviceCode code={flow.start.user_code} copied={flow.copied} onCopy={() => void copyDeviceCode()} />
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
@@ -842,24 +914,53 @@ function Step({ children, title }: { children: React.ReactNode; title: string })
)
}
function CodeBlock({
copied,
large,
onCopy,
text
}: {
copied: boolean
large?: boolean
onCopy: () => void
text: string
}) {
// Device-code display: OTP-style — each character in its own readonly cell.
// The whole row is the copy button (no side button, no checkmark); on copy the
// cells flash emerald for feedback. Dashes render as quiet separators.
function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
const { t } = useI18n()
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
<button
aria-label={t.onboarding.copy}
className="group flex w-full items-center justify-center gap-1.5"
onClick={onCopy}
type="button"
>
{[...code].map((ch, i) =>
ch === '-' || ch === ' ' ? (
<span className="w-1.5 text-center text-lg text-muted-foreground" key={i}>
</span>
) : (
<span
className={cn(
'flex size-10 items-center justify-center rounded-md border font-mono text-xl font-semibold uppercase transition-colors',
copied
? 'border-primary/50 text-primary'
: 'border-(--stroke-nous) text-foreground group-hover:border-(--ui-stroke-secondary)'
)}
key={i}
>
{ch}
</span>
)
)}
</button>
)
}
function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
const { t } = useI18n()
return (
<div className="flex items-center justify-between gap-3 rounded-md border border-(--stroke-nous) px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-sm">
<span className="mr-2 select-none text-muted-foreground">$</span>
{text}
</code>
<Button onClick={onCopy} size="sm" variant="outline">
{copied ? <Check className="size-4" /> : t.onboarding.copy}
{copied ? t.common.copied : t.onboarding.copy}
</Button>
</div>
)
@@ -884,14 +985,184 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
)
}
// Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
// that decodes left-to-right from scrambled glyphs into the real text, with a
// blinking block cursor. Ties onboarding's success moment to that same motif.
// Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
// Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
// Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
const SCRAMBLE_GLYPHS = [...'𒀀𒀁𒀂𒀅𒀊𒀖𒀜𒀭𒀲𒀸𒁀𒁉𒁒𒁕𒁹𒂊𒃻𒄆𒄴𒅀𒆍𒇽𒈨𒉡']
const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
// How many trailing characters of each word scramble during decode-in.
const DECODE_TAIL = 4
// Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
// (resolved Latin chars stay full size) — keeps the easter-egg glyphs subtle.
function GlyphText({ text }: { text: string }) {
return (
<>
{Array.from(text, (ch, i) =>
GLYPH_SET.has(ch) ? (
<span className="text-[0.62em]" key={i}>
{ch}
</span>
) : (
ch
)
)}
</>
)
}
function useDecoded(text: string): string {
const [out, setOut] = useState(text)
useEffect(() => {
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
setOut(text)
return
}
// Each WORD keeps its head static and only churns its tail (last few chars),
// resolving left-to-right across all tails — same anchor-the-prefix trick the
// connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
// so both the provider and "CONNECTED" decode and time stays constant.
const chars = [...text]
const scrambleable = chars.map(() => false)
for (let i = 0; i < chars.length; ) {
if (!/[a-z0-9]/i.test(chars[i])) {
i += 1
continue
}
let j = i
while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
j += 1
}
for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
scrambleable[k] = true
}
i = j
}
const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
let resolved = 0
const id = window.setInterval(() => {
resolved += 0.5
const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
if (Math.floor(resolved) >= tailIndices.length) {
window.clearInterval(id)
}
}, 45)
return () => window.clearInterval(id)
}, [text])
return out
}
// Continuously scrambles alphanumeric chars while `active` (used on exit so the
// model name / button decay into ascii noise as they fade).
function useScramble(text: string, active: boolean): string {
const [out, setOut] = useState(text)
useEffect(() => {
if (!active) {
setOut(text)
return
}
const id = window.setInterval(() => {
setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
}, 45)
return () => window.clearInterval(id)
}, [text, active])
return out
}
function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
const decoded = useDecoded(text.toUpperCase())
return (
<span
className={cn(
'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
<GlyphText text={decoded} />
<span
aria-hidden="true"
className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
/>
<style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
</span>
)
}
// Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
// mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
// hover. The whole onboarding "you're in" moment leans into this motif.
function HackeryButton({
disabled,
label,
loading,
onClick
}: {
disabled?: boolean
label: React.ReactNode
loading?: boolean
onClick: () => void
}) {
return (
<button
className={cn(
'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
'font-mono text-xs font-semibold uppercase text-primary',
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
'disabled:pointer-events-none disabled:opacity-50'
)}
disabled={disabled}
onClick={onClick}
type="button"
>
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
</button>
)
}
function ConfirmingModelPanel({
ctx,
flow
flow,
leaving,
onBegin
}: {
ctx: OnboardingContext
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
leaving: boolean
onBegin: () => void
}) {
const { t } = useI18n()
const scrambledModel = useScramble(flow.currentModel, leaving)
const scrambledBegin = useScramble(t.onboarding.startChatting, leaving)
// Local state controls whether the model picker dialog is open.
// We reuse the existing ModelPickerDialog component (the same picker
// available from the chat shell) rather than building an inline
@@ -914,46 +1185,61 @@ function ConfirmingModelPanel({
const freeTier = providerRow?.free_tier
return (
<div className="grid gap-4">
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4 shrink-0" />
<span>{t.onboarding.connectedProvider(flow.label)}</span>
</div>
<div className="grid place-items-center gap-7 py-6 text-center">
<DecodedLabel leaving={leaving} text={t.onboarding.connectedProvider(flow.label)} />
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t.onboarding.defaultModel}</p>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
{t.onboarding.pro}
</span>
)}
</div>
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
{price && (price.input || price.output) && (
<p className="mt-1 font-mono text-xs text-muted-foreground">
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
</div>
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
{t.onboarding.change}
</Button>
<div
className={cn(
'grid justify-items-center gap-1.5 transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">
{t.onboarding.defaultModel}
</span>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
{t.onboarding.pro}
</span>
)}
</div>
<p className="font-mono text-base">
<GlyphText text={scrambledModel} />
</p>
{price && (price.input || price.output) && (
<p className="font-mono text-xs text-muted-foreground">
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
<Button
className="mt-0.5 text-xs"
disabled={flow.saving}
onClick={() => setPickerOpen(true)}
size="inline"
variant="text"
>
{t.onboarding.change}
</Button>
</div>
<div className="flex justify-end">
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{t.onboarding.startChatting}
</Button>
<div
className={cn(
'transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<HackeryButton
disabled={flow.saving}
label={<GlyphText text={scrambledBegin} />}
loading={flow.saving}
onClick={onBegin}
/>
</div>
{/*
@@ -981,7 +1267,7 @@ function ConfirmingModelPanel({
function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
return (
<Button asChild size="xs" variant="ghost">
<Button asChild size="xs" variant="text">
<a href={href} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{children}
@@ -992,8 +1278,8 @@ function DocsLink({ children, href }: { children: React.ReactNode; href: string
function Status({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<div className="flex items-center gap-2.5 py-1 text-sm text-muted-foreground" role="status">
<Loader className="size-7" type="lemniscate-bloom" />
{children}
</div>
)
+1 -1
View File
@@ -128,7 +128,7 @@ export function ModelPickerDialog({
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
@@ -135,16 +136,18 @@ export function ModelVisibilityDialog({
</div>
<div className="px-3 py-2">
<button
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
<Button
className="-ml-2 text-(--ui-text-tertiary)"
onClick={() => {
onOpenChange(false)
onOpenProviders()
}}
size="xs"
type="button"
variant="text"
>
{copy.addProvider}
</button>
</Button>
</div>
</DialogContent>
</Dialog>
+17 -13
View File
@@ -3,6 +3,7 @@ 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'
@@ -26,8 +27,7 @@ const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; v
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
}
const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md'
const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
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)
@@ -83,12 +83,12 @@ export function NotificationStack() {
{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={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
<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={GHOST_BTN} onClick={clearNotifications} type="button">
</Button>
<Button className="-mr-2" onClick={clearNotifications} size="xs" type="button" variant="text">
{copy.clearAll}
</button>
</Button>
</div>
)}
</div>,
@@ -117,27 +117,31 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
<p className="m-0">{notification.message}</p>
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<button
className="mt-1.5 inline-flex items-center rounded-md bg-primary/15 px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/25"
<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>
</Button>
)}
</AlertDescription>
</div>
<button
<Button
aria-label={copy.dismiss}
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
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>
</Button>
</Alert>
)
}
@@ -149,7 +153,7 @@ function NotificationDetail({ detail }: { detail: string }) {
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 border border-border/70 bg-background/65 p-2">
<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>
@@ -103,10 +103,7 @@ function SudoDialog() {
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
{copy.sudoTitle}
</DialogTitle>
<DialogTitle icon={Lock}>{copy.sudoTitle}</DialogTitle>
<DialogDescription>{copy.sudoDesc}</DialogDescription>
</DialogHeader>
@@ -200,10 +197,7 @@ function SecretDialog() {
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || copy.secretTitle}
</DialogTitle>
<DialogTitle icon={KeyRound}>{request.envVar || copy.secretTitle}</DialogTitle>
<DialogDescription>{request.prompt || copy.secretDesc}</DialogDescription>
</DialogHeader>
@@ -18,7 +18,7 @@ export function ActionStatus({
}) {
return (
<>
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
{state === 'saving' ? <Loader2 className="animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
{state === 'saving' ? busy : state === 'done' ? done : idle}
</>
)
+11 -4
View File
@@ -8,17 +8,20 @@ import { cn } from '@/lib/utils'
// fixed heights — so they stay snug and scale with content. Only icon buttons
// (inherently square) carry the shared 4px radius.
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap shadow-none transition-all duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
// Quiet action — transparent fill with a 1.5px inset ring (no layout-shifting border).
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'bg-transparent text-(--ui-text-primary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)] hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
// Soft-fill action (the default "non-primary button" look).
secondary:
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
@@ -32,6 +35,10 @@ const buttonVariants = cva(
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'px-2.5 py-1 has-[>svg]:px-2',
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
// Flush inline text action — no box padding/height. Pair with text/link
// variants when the button must sit inline in a heading or sentence
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
+23 -6
View File
@@ -2,8 +2,8 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
@@ -53,7 +53,7 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
@@ -68,7 +68,7 @@ function DialogContent({
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="1rem" />
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
@@ -98,13 +98,30 @@ function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({
className,
icon: Icon,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
// Pass a lucide icon to get the canonical dialog-header glyph: a plain
// primary-tinted icon inline with the title (no bg chip / ring). This is the
// single source of truth for dialog header icons — don't hand-roll wrappers.
icon?: React.ComponentType<{ className?: string }>
}) {
return (
<DialogPrimitive.Title
className={cn('text-[0.9375rem] font-semibold tracking-tight text-foreground', className)}
className={cn(
'text-[0.9375rem] font-semibold tracking-tight text-foreground',
Icon && 'flex items-center gap-2',
className
)}
data-slot="dialog-title"
{...props}
/>
>
{Icon ? <Icon className="size-4 shrink-0 text-primary" /> : null}
{children}
</DialogPrimitive.Title>
)
}
+14 -9
View File
@@ -1,8 +1,15 @@
import type { ReactNode } from 'react'
import { AlertCircle } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// The single canonical error glyph (codicon's filled error mark). Use this
// everywhere an error is surfaced (boundaries, dialogs, banners) so failures
// read identically — one icon, one color, no background chip.
export function ErrorIcon({ className, size = '1.75rem' }: { className?: string; size?: string }) {
return <Codicon className={cn('text-destructive', className)} name="error" size={size} />
}
export interface ErrorStateProps {
/** Optional actions row/stack rendered below the copy. */
children?: ReactNode
@@ -13,18 +20,16 @@ export interface ErrorStateProps {
title: ReactNode
}
// Shared, presentation-only error layout: a destructive icon chip over a
// centered title + body, with an optional actions stack. Used by both the
// top-level React error boundary and the in-dialog update error so every
// failure state reads the same. Title/description accept nodes so callers in a
// Radix Dialog can pass DialogTitle/DialogDescription for accessibility.
// Shared, presentation-only error layout: the canonical ErrorIcon (no bg chip)
// over a centered title + body, with an optional actions stack. Used by the
// React error boundary, the in-dialog update error, and the boot-failure banner
// so every failure reads the same. Title/description accept nodes so Radix
// Dialog callers can pass DialogTitle/DialogDescription for accessibility.
export function ErrorState({ children, className, description, icon, title }: ErrorStateProps) {
return (
<div className={cn('grid gap-5', className)}>
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
{icon ?? <AlertCircle className="size-7" />}
</span>
{icon ?? <ErrorIcon />}
{typeof title === 'string' ? (
<h2 className="text-center text-xl font-semibold tracking-tight">{title}</h2>
@@ -0,0 +1,17 @@
import type { ComponentProps } from 'react'
import { cn } from '@/lib/utils'
// Shared raw-log viewer: no bg, hairline border, tight padding, small mono.
// One style everywhere we surface logs. Pass a max-h-* via className.
export function LogView({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn(
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary) [scrollbar-width:thin]',
className
)}
{...props}
/>
)
}
+2 -2
View File
@@ -1327,7 +1327,7 @@ export const en: Translations = {
},
startingSignIn: provider => `Starting sign-in for ${provider}...`,
verifyingCode: provider => `Verifying your code with ${provider}...`,
connectedProvider: provider => `${provider} connected.`,
connectedProvider: provider => `${provider} connected`,
connectedPicking: provider => `${provider} connected. Picking a default model...`,
signInFailed: 'Sign-in failed. Try again.',
pickDifferentProvider: 'Pick a different provider',
@@ -1353,7 +1353,7 @@ export const en: Translations = {
free: 'Free',
price: (input, output) => `${input} in / ${output} out per Mtok`,
change: 'Change',
startChatting: 'Start chatting',
startChatting: 'Begin',
docs: provider => `${provider} docs`
},
+6 -3
View File
@@ -83,7 +83,8 @@ const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const SKIP_CACHE_KEY = 'hermes-onboarding-skipped-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
export const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
export const DEFAULT_MANUAL_ONBOARDING_REASON = 'Add or switch inference provider.'
function readCachedConfigured(): boolean | null {
if (typeof window === 'undefined') {
@@ -387,7 +388,7 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
// duplicating provider UI. Sets manual=true so the overlay shows the picker
// even though configured===true, and refreshes the provider list.
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONBOARDING_REASON) {
patch({
manual: true,
requested: true,
@@ -857,7 +858,9 @@ export function confirmOnboardingModel(ctx: OnboardingContext) {
return
}
notifyReady(flow.label)
// No success toast here: the confirm-model screen already showed "<provider>
// connected." notifyReady is reserved for completion paths that SKIP this
// screen (no-default fallthrough, local endpoint) so feedback isn't lost.
completeDesktopOnboarding()
ctx.onCompleted?.()
}
+17 -31
View File
@@ -60,7 +60,6 @@
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--shadow-ink: var(--dt-foreground);
--shadow-xs: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-sm:
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 6%, transparent),
@@ -69,19 +68,24 @@
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
0 0.25rem 1rem color-mix(in srgb, #000 8%, transparent),
0 1rem 2rem -1.5rem color-mix(in srgb, #000 18%, transparent);
/* Soft floating shadow for borderless modals/overlays. Single top light
source: every layer is centered (x=0) and cast downward, with negative
spread that grows with the blur so each layer is pulled horizontally inward
— the shadow pools below the panel instead of bleeding out every side.
Layered (contact → ambient) for a smooth, natural falloff. */
--shadow-nous:
0 0.125rem 0.25rem -0.125rem color-mix(in srgb, #000 7%, transparent),
0 0.5rem 0.75rem -0.375rem color-mix(in srgb, #000 6%, transparent),
0 1.25rem 1.75rem -0.875rem color-mix(in srgb, #000 6%, transparent),
0 2.25rem 3rem -1.75rem color-mix(in srgb, #000 0%, transparent);
/* Hairline border paired with --shadow-nous on borderless overlays.
currentColor resolves per-element, so it adapts to text color/theme. */
--stroke-nous: color-mix(in srgb, currentColor 3%, transparent);
--shadow-lg:
inset 0 0.0625rem 0 color-mix(in srgb, #fff 28%, transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
0 0.75rem 2rem color-mix(in srgb, #000 12%, transparent);
--shadow-header:
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-composer-focus:
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
0 0.25rem 0.875rem color-mix(in srgb, #000 8%, transparent),
0 0.75rem 2rem -1.25rem color-mix(in srgb, #000 14%, transparent);
}
@layer base {
@@ -610,28 +614,10 @@ button {
-webkit-app-region: no-drag;
}
[data-slot='button'] {
box-shadow: none;
transition-duration: 100ms;
}
[data-slot='button'][data-variant='outline'],
[data-slot='button'][data-variant='secondary'] {
border-color: var(--ui-stroke-secondary);
background: var(--ui-bg-tertiary);
color: var(--ui-text-primary);
}
[data-slot='button'][data-variant='ghost'] {
color: var(--ui-text-secondary);
}
[data-slot='button'][data-variant='outline']:hover,
[data-slot='button'][data-variant='secondary']:hover,
[data-slot='button'][data-variant='ghost']:hover {
background: var(--chrome-action-hover);
color: var(--ui-text-primary);
}
/* Button variant styling lives entirely in the cva in components/ui/button.tsx
(the single source of truth). Don't re-add [data-slot='button'] rules here —
attribute selectors out-specify the Tailwind utilities and silently override
the variants. */
[data-slot='dropdown-menu-content'],
[data-slot='select-content'],