mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
c79e3fd0ba
commit
f033b7dbfb
33 changed files with 1099 additions and 1145 deletions
BIN
apps/desktop/public/nous-girl.jpg
Normal file
BIN
apps/desktop/public/nous-girl.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
apps/desktop/src/components/brand-mark.tsx
Normal file
16
apps/desktop/src/components/brand-mark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' }) {
|
|||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
17
apps/desktop/src/components/ui/log-view.tsx
Normal file
17
apps/desktop/src/components/ui/log-view.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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`
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue