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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1099 additions and 1145 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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}{' '}

View file

@ -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' }) {
)
}
function ConfirmingModelPanel({
ctx,
flow
// 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({
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>
)

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}

View file

@ -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>

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>

View file

@ -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>

View file

@ -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}
</>
)

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]',

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>
)
}

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>

View file

@ -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}
/>
)
}