feat(desktop): wire project settings and shell chrome

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent 62af32efe7
commit b8d220f268
9 changed files with 116 additions and 35 deletions

View file

@ -11,7 +11,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
@ -26,6 +26,7 @@ import {
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { slug } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -519,12 +520,13 @@ function CreateProfileDialog({
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Input
<SanitizedInput
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
onValueChange={setName}
placeholder="my-profile"
sanitize={slug}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
@ -648,11 +650,12 @@ function RenameProfileDialog({
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<Input
<SanitizedInput
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
onValueChange={setName}
sanitize={slug}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>

View file

@ -3,8 +3,9 @@ import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@ -117,7 +118,7 @@ export function AboutSettings() {
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}

View file

@ -1,3 +1,4 @@
import { codiconIcon } from '@/components/ui/codicon'
import {
Brain,
type IconComponent,
@ -7,7 +8,6 @@ import {
Monitor,
Moon,
Palette,
Sparkles,
Sun,
Wrench
} from '@/lib/icons'
@ -501,7 +501,7 @@ export const SECTIONS: DesktopConfigSection[] = [
{
id: 'model',
label: 'Model',
icon: Sparkles,
icon: codiconIcon('hubot'),
keys: ['model_context_length', 'fallback_providers']
},
{

View file

@ -1,11 +1,11 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { codiconIcon } from '@/components/ui/codicon'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -120,7 +120,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
icon={codiconIcon('account')}
label={t.settings.nav.providerAccounts}
nested
onClick={() => openProviderView('accounts')}
@ -186,7 +186,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
<Download className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.importConfig}>
@ -196,7 +196,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
importInputRef.current?.click()
}}
>
<IconUpload className="size-3.5" />
<Upload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label={t.settings.resetToDefaults}>
@ -207,7 +207,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
void resetConfig()
}}
>
<IconRefresh className="size-3.5" />
<RefreshCw className="size-3.5" />
</OverlayIconButton>
</Tip>
</div>

View file

@ -8,6 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@ -62,7 +63,9 @@ export function SessionsSettings() {
try {
await setSessionArchived(session.id, false, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
// Surface it again in the sidebar without waiting for a full refresh, and
// lift any optimistic eviction so the grouped tree shows it again too.
untombstoneSessions([session.id, session._lineage_root_id])
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
triggerHaptic('selection')
notify({ durationMs: 2_000, kind: 'success', message: s.restored })

View file

@ -1,10 +1,8 @@
import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle } from '@/lib/icons'
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
import type { StatusResponse } from '@/types/hermes'
@ -88,7 +86,7 @@ export function GatewayMenuPanel({
size="icon-sm"
variant="ghost"
>
<IconLayoutDashboard />
<LayoutDashboard />
</Button>
</Tip>
</div>

View file

@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import {
@ -13,7 +14,6 @@ import {
Command,
Hash,
Loader2,
Sparkles,
Terminal,
Zap,
ZapFilled
@ -322,7 +322,7 @@ export function useStatusbarItems({
) : subagentsRunning > 0 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Sparkles className="size-3" />
<Codicon name="hubot" size="0.75rem" />
),
id: 'agents',
label: copy.agents,

View file

@ -471,6 +471,31 @@
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
}
/* Hover-reveal suppression the shared, declarative escape hatch.
A collapsed pane slides in when the pointer dwells on its thin edge trigger.
Controls that sit over that edge gutter would drag the panel in by accident.
Mark any such region and, while it's hovered, the matching edge trigger(s) go
pointer-transparent. Auto-resets on mouse-out, no JS.
- data-suppress-pane-reveal kills BOTH edges (use for small regions
like the thread timeline).
- data-suppress-pane-reveal-side kills only the hovered region's OWN side
(side read from its [data-pane-side]
pane ancestor), so the opposite sidebar
stays summonable while you work a pane. */
[data-pane-shell]:has([data-suppress-pane-reveal]:hover) [data-pane-reveal-trigger] {
pointer-events: none;
}
[data-pane-shell]:has([data-pane-side='left'] [data-suppress-pane-reveal-side]:hover)
[data-pane-side='left']
[data-pane-reveal-trigger],
[data-pane-shell]:has([data-pane-side='right'] [data-suppress-pane-reveal-side]:hover)
[data-pane-side='right']
[data-pane-reveal-trigger] {
pointer-events: none;
}
:root:not([style*='--theme-asset-bg:']) .theme-default-filler {
display: block;
}
@ -709,6 +734,28 @@ canvas {
-webkit-user-drag: none;
}
/* Tabs render 2-wide on every HTML code surface the source preview, inline
diffs, markdown code blocks, tool output so indentation matches the editor
and the terminal instead of the browser default (8). */
pre,
code {
tab-size: 2;
-moz-tab-size: 2;
}
/* Arc-style multicolor action surface (static, not animated). Reusable on any
Button via className. Unlayered so it beats Tailwind's bg-*/text-* variant
utilities. */
.btn-arc {
background-image: linear-gradient(110deg, #5b6cff 0%, #8b5cf6 28%, #d946ef 58%, #fb7185 82%, #fb923c 100%);
color: #fff;
border-color: transparent;
}
.btn-arc:hover:not(:disabled) {
filter: brightness(1.08) saturate(1.06);
}
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
.desktop-input-chrome {
--ring-pct: 18%;
@ -1064,12 +1111,6 @@ canvas {
border-color: var(--ui-stroke-secondary) !important;
}
/* On focus we don't change the fill just shift the border ~15% toward the
foreground, which darkens it in light mode and lightens it in dark mode. */
[data-slot='composer-surface']:focus-within {
border-color: color-mix(in srgb, var(--ui-stroke-secondary) 85%, var(--dt-foreground)) !important;
}
[data-slot='composer-fade'] {
min-height: 2.375rem;
}
@ -1235,21 +1276,48 @@ canvas {
opacity: 1;
}
/* Syntax-highlighted inline diff (Shiki): strip the theme's own surface +
default margins so context lines stay transparent and each changed line owns
its tint. `display: grid` on the code puts one `.line` per row and drops the
whitespace-only `\n` nodes between them without it, full-width block lines
double up with the literal newlines (phantom blank rows). */
/* Shiki surfaces in the inline file diff + source preview: strip the theme's
own background/margins, and lay each `.line` on its own grid row so the
inter-line `\n` text nodes can't double-space full-width rows. Empty lines
carry a non-breaking space so blank rows keep their height. */
[data-slot='file-diff-panel'] .shiki,
[data-slot='file-diff-panel'] .shiki code {
[data-slot='file-diff-panel'] .shiki code,
.preview-source-code .shiki,
.preview-source-code .shiki code {
margin: 0;
background: transparent !important;
}
[data-slot='file-diff-panel'] .shiki code {
[data-slot='file-diff-panel'] .shiki code,
.preview-source-code .shiki code {
display: grid;
}
[data-slot='file-diff-panel'] .shiki code .line:empty::before,
.preview-source-code .shiki code .line:empty::before {
content: '\00a0';
}
/* Inline diff rows keep their utility-class padding; just floor the height. */
[data-slot='file-diff-panel'] .shiki code .line {
min-height: 1.25rem;
white-space: pre;
}
/* Source rows are a fixed editor-height box so sourcediff toggling never
shifts and the gutter stays aligned. */
.preview-source-code .shiki code {
min-width: max-content;
}
.preview-source-code .shiki code .line {
display: block;
height: 1.25rem;
padding: 0 0.625rem;
line-height: 1.25rem;
white-space: pre;
}
/* The github-dark token palette reads candy-bright at our small code size.
`github-dark-dimmed` only dims the *background* (which we strip), so soften
the token *foregrounds* directly a small saturation + brightness pullback,

View file

@ -54,6 +54,14 @@ let
npm exec tsc -b
npm exec vite build
# Bundle the electron main into a single self-contained file so
# the nix output doesn't need node_modules/. simple-git (the only
# external runtime dep of the electron main) gets inlined; electron
# and node-pty are external (provided by the runtime / native-deps).
# preload.cjs stays separate — Electron loads it via __dirname, not
# require(), so it must remain a standalone file.
node scripts/bundle-electron-main.mjs
popd
runHook postBuild