Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62871ea4b1 | ||
|
|
b758357c98 | ||
|
|
3c163cb035 | ||
|
|
86643d84e9 | ||
|
|
bc9e33d66b | ||
|
|
38acced687 | ||
|
|
5bb7156949 | ||
|
|
3a5e36cfa5 | ||
|
|
aecdc75bb0 | ||
|
|
9e02b18828 | ||
|
|
fd68ae6331 | ||
|
|
e026fd88cd | ||
|
|
fd88d527af | ||
|
|
88bdb6b074 | ||
|
|
ded620b711 | ||
|
|
311e80809f | ||
|
|
ac9de2e80c | ||
|
|
40420a619b | ||
|
|
2e628ae971 | ||
|
|
30c7b787d1 | ||
|
|
03ba06ebfb | ||
|
|
e68fc4def2 | ||
|
|
e45dd2b0e7 | ||
|
|
e2ea648a08 | ||
|
|
75e29f97ee | ||
|
|
947f305f84 | ||
|
|
41ede96304 | ||
|
|
f15d2cb5e4 | ||
|
|
2b762c5364 | ||
|
|
75adf7d603 | ||
|
|
0776d1b19c | ||
|
|
d6e2c940e9 | ||
|
|
fb0250ef63 | ||
|
|
1e1ab31ad6 | ||
|
|
8c0f15478d | ||
|
|
712bf4d8e4 | ||
|
|
35a750eedd | ||
|
|
7402706c5e | ||
|
|
72f556dfc4 | ||
|
|
58eb473baa |
+5
-2
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -185,13 +185,16 @@ RUN cd web && npm run build && \
|
||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
||||
# not chowned here.
|
||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
||||
# gateway state artifacts beneath the package after services drop privileges,
|
||||
# especially when the hermes UID is remapped at boot (#27221).
|
||||
# The .venv MUST remain hermes-writable so lazy_deps.py can install
|
||||
# remaining optional platform packages and future pin bumps at first use.
|
||||
# Without this, `uv pip install` fails with EACCES and adapters silently
|
||||
# fail to load. See tools/lazy_deps.py.
|
||||
USER root
|
||||
RUN chmod -R a+rX /opt/hermes && \
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
||||
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
||||
# the data volume. Each supervised service then drops to the hermes user via
|
||||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||
|
||||
@@ -471,12 +471,14 @@ let bootstrapAbortController = null
|
||||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||||
let forceBootstrapRepair = false
|
||||
let connectionConfigCache = null
|
||||
let connectionConfigCacheMtime = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
let previewShortcutActive = false
|
||||
let desktopLogBuffer = ''
|
||||
let desktopLogFlushTimer = null
|
||||
let desktopLogFlushPromise = Promise.resolve()
|
||||
let nativeThemeListenerInstalled = false
|
||||
let bootProgressState = {
|
||||
error: null,
|
||||
fakeMode: BOOT_FAKE_MODE,
|
||||
@@ -1101,9 +1103,17 @@ function readDesktopUpdateConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic file write: temp + rename (atomic on all platforms). Prevents
|
||||
// partial writes on crash/power loss that corrupt JSON config files.
|
||||
function writeFileAtomic(targetPath, data, encoding) {
|
||||
const tmp = targetPath + '.tmp'
|
||||
fs.writeFileSync(tmp, data, encoding)
|
||||
fs.renameSync(tmp, targetPath)
|
||||
}
|
||||
|
||||
function writeDesktopUpdateConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
// Match the backend's source resolution but bias toward a real git checkout.
|
||||
@@ -1628,7 +1638,7 @@ function writeBootstrapMarker(payload) {
|
||||
completedAt: new Date().toISOString(),
|
||||
desktopVersion: app.getVersion()
|
||||
}
|
||||
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||||
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2094,6 +2104,7 @@ function fetchJson(url, token, options = {}) {
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
@@ -2433,6 +2444,7 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
@@ -3135,7 +3147,17 @@ function decryptDesktopSecret(secret) {
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
if (connectionConfigCache) {
|
||||
// Check if file changed on disk since last read (e.g. modified by another
|
||||
// process or an external tool). Our own writes update the cache inline
|
||||
// via writeDesktopConnectionConfig, but external changes would be missed.
|
||||
let mtime = null
|
||||
try {
|
||||
mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
} catch {
|
||||
mtime = null
|
||||
}
|
||||
|
||||
if (connectionConfigCache && connectionConfigCacheMtime === mtime) {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
@@ -3156,14 +3178,16 @@ function readDesktopConnectionConfig() {
|
||||
}
|
||||
|
||||
connectionConfigCache = config
|
||||
connectionConfigCacheMtime = mtime
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function writeDesktopConnectionConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
connectionConfigCache = config
|
||||
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
}
|
||||
|
||||
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
@@ -3491,9 +3515,12 @@ function createWindow() {
|
||||
}
|
||||
|
||||
if (!IS_MAC) {
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -25,7 +24,9 @@ import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
@@ -372,14 +373,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
|
||||
const [imagePage, setImagePage] = useState(1)
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
@@ -398,11 +396,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
} catch (err) {
|
||||
notifyError(err, 'Artifacts failed to load')
|
||||
setArtifacts([])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshArtifacts)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshArtifacts()
|
||||
}, [refreshArtifacts])
|
||||
@@ -502,7 +500,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={counts.all === 0}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
@@ -518,23 +520,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
@@ -549,10 +534,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 px-2 pb-2">
|
||||
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
@@ -578,7 +569,13 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
@@ -588,7 +585,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
|
||||
</div>
|
||||
</section>
|
||||
@@ -660,11 +657,7 @@ interface ArtifactImageCardProps {
|
||||
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
|
||||
)}
|
||||
>
|
||||
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
|
||||
@@ -674,7 +667,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
{!failedImage && (
|
||||
<ZoomableImage
|
||||
alt={artifact.label}
|
||||
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
|
||||
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
|
||||
containerClassName="max-h-full"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
@@ -702,7 +695,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3" />
|
||||
Chat
|
||||
</Button>
|
||||
@@ -741,10 +734,7 @@ function ArtifactCellAction({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
type="button"
|
||||
@@ -863,7 +853,7 @@ function ArtifactTable({
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<tbody>
|
||||
{artifacts.map(artifact => (
|
||||
<tr className="group/artifact" key={artifact.id}>
|
||||
{ARTIFACT_COLUMNS.map(col => {
|
||||
|
||||
@@ -137,7 +137,7 @@ function PromptSnippetsDialog({
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
|
||||
@@ -73,9 +73,39 @@ import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
// which is when the composer should expand to the stacked layout.
|
||||
const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -142,6 +172,7 @@ export function ChatBar({
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
@@ -157,6 +188,35 @@ export function ChatBar({
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
// *different* conversation. Critically, the first id assignment of a freshly
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSessionIdRef.current
|
||||
prevSessionIdRef.current = sessionId
|
||||
|
||||
if (prev === sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
// null → id: the new session we're already in just got persisted. Keep the
|
||||
// starter we showed instead of swapping to a follow-up under the user.
|
||||
if (prev == null && sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
@@ -164,7 +224,7 @@ export function ChatBar({
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
: 'Send follow-up'
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
focusComposerInput(editorRef.current)
|
||||
@@ -255,14 +315,13 @@ export function ChatBar({
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
// Track expansion via cheap heuristics (newline or length threshold) instead
|
||||
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
|
||||
// synchronous layout flush — measured at 2.27 layouts per character typed
|
||||
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
|
||||
// composer-default-width, this heuristic flips at roughly the right time
|
||||
// and the user only notices if they type far past the wrap boundary
|
||||
// without a newline; in that case the ResizeObserver below catches it via
|
||||
// a height delta and we still expand.
|
||||
// Expansion (input on its own full-width row, controls below) is driven by
|
||||
// the editor's *actual* rendered height via the ResizeObserver in
|
||||
// syncComposerMetrics — it only fires when the text genuinely wraps to a
|
||||
// second line, so the layout flips exactly at the wrap point rather than at
|
||||
// a guessed character count. We only handle the two cases the observer
|
||||
// can't: an explicit newline (expand before layout settles) and an emptied
|
||||
// draft (collapse back). We never read scrollHeight per keystroke.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
@@ -274,7 +333,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (draft.includes('\n') || draft.length > 60) {
|
||||
if (draft.includes('\n')) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
@@ -310,6 +369,18 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Expand once the input has actually wrapped past a single line. The
|
||||
// observer only fires on real size changes, so this reads scrollHeight at
|
||||
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
|
||||
// min-height + padding); a second line clears ~36px. We only ever expand
|
||||
// here — collapse is handled by the emptied-draft effect to avoid
|
||||
// oscillating across the wrap boundary as the input switches widths.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
if (height > 0) {
|
||||
const bucket = Math.round(height / 8) * 8
|
||||
|
||||
@@ -329,7 +400,7 @@ export function ChatBar({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef)
|
||||
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -407,13 +478,19 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
@@ -476,6 +553,13 @@ export function ChatBar({
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend will
|
||||
// deliver the finalized text via a clean input event.
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
@@ -576,13 +660,17 @@ export function ChatBar({
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// IME composition: Enter confirms composed text, not a message submission.
|
||||
// Without this guard, pressing Enter to finalise a Korean/Japanese/Chinese
|
||||
// IME preedit fires submitDraft() and splits the message mid-word.
|
||||
if (event.nativeEvent.isComposing) {
|
||||
// We check both composingRef (set by compositionstart/compositionend, robust
|
||||
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
|
||||
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
|
||||
// preedit fires submitDraft() and splits the message mid-word.
|
||||
if (composingRef.current || event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
|
||||
if (!busy) {
|
||||
@@ -971,7 +1059,8 @@ export function ChatBar({
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
}
|
||||
|
||||
focusInput()
|
||||
@@ -1100,7 +1189,7 @@ export function ChatBar({
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
@@ -1110,6 +1199,12 @@ export function ChatBar({
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
@@ -1158,6 +1253,9 @@ export function ChatBar({
|
||||
onDrop={handleDrop}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
@@ -1260,7 +1358,7 @@ export function ChatBar({
|
||||
'grid w-full',
|
||||
stacked
|
||||
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>
|
||||
|
||||
@@ -98,9 +98,12 @@ function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
const activeStoredSession =
|
||||
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
|
||||
// Pins live on the durable lineage-root id, but selectedSessionId is the live
|
||||
// (tip) id — resolve through the loaded row so the menu reflects the pin
|
||||
// state after auto-compression rotates the id.
|
||||
@@ -110,6 +113,13 @@ function ChatHeader({
|
||||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -123,7 +133,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -82,7 +82,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -481,7 +482,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview…</div>
|
||||
return <PageLoader label="Loading preview" />
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
|
||||
@@ -83,7 +83,7 @@ function PreviewLoadError({
|
||||
body={
|
||||
<>
|
||||
<a
|
||||
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
className="pointer-events-auto block font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
@@ -608,7 +608,7 @@ export function PreviewPane({
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -36,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarOpen,
|
||||
@@ -214,6 +216,7 @@ export function ChatSidebar({
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
@@ -227,8 +230,28 @@ export function ChatSidebar({
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
|
||||
// the shortcut visibly pings its affordance in the sidebar.
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const onShortcut = () => {
|
||||
setNewSessionKbdFlash(true)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => setNewSessionKbdFlash(false), 140)
|
||||
}
|
||||
|
||||
window.addEventListener('hermes:new-session-shortcut', onShortcut)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hermes:new-session-shortcut', onShortcut)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
const dndSensors = useSensors(
|
||||
@@ -406,7 +429,8 @@ export function ChatSidebar({
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
|
||||
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
|
||||
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0'
|
||||
@@ -430,7 +454,7 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
@@ -445,7 +469,10 @@ export function ChatSidebar({
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -458,28 +485,13 @@ export function ChatSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 pb-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
|
||||
<input
|
||||
aria-label="Search sessions"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions…"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label="Search sessions"
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -554,7 +566,7 @@ export function ChatSidebar({
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@@ -604,7 +616,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
|
||||
return (
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
@@ -645,7 +657,7 @@ function SidebarPinnedEmptyState() {
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
<span>Shift-click a chat to pin</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -848,7 +860,7 @@ function SidebarWorkspaceGroup({
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
@@ -863,7 +875,7 @@ function SidebarWorkspaceGroup({
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
@@ -895,7 +907,7 @@ function SidebarWorkspaceGroup({
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
@@ -949,7 +961,7 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -6,6 +7,7 @@ import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
|
||||
@@ -61,6 +63,10 @@ export function SidebarSessionRow({
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
@@ -84,9 +90,9 @@ export function SidebarSessionRow({
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && <span aria-hidden="true" className="arc-border" />}
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
@@ -114,16 +120,25 @@ export function SidebarSessionRow({
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={handleLabel}
|
||||
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
className={cn(
|
||||
// Scope the dot↔grabber swap to a local group so the grabber
|
||||
// only reveals when hovering/focusing the handle itself, not
|
||||
// anywhere on the row.
|
||||
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// The quest-glow box-shadow extends past the dot; let it bleed
|
||||
// out instead of being clipped by this handle's overflow-hidden.
|
||||
needsInput && 'overflow-visible'
|
||||
)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
/>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
@@ -131,8 +146,8 @@ export function SidebarSessionRow({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
|
||||
<SidebarRowDot isWorking={isWorking} />
|
||||
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
@@ -155,7 +170,7 @@ export function SidebarSessionRow({
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
@@ -169,7 +184,30 @@ export function SidebarSessionRow({
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
|
||||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
className
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
// pulse of an active turn.
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label="Needs your input"
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title="Waiting for your answer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Cpu,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
KeyRound,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Moon,
|
||||
Package,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
COMMAND_CENTER_ROUTE,
|
||||
CRON_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
NEW_CHAT_ROUTE,
|
||||
PROFILES_ROUTE,
|
||||
sessionRoute,
|
||||
SETTINGS_ROUTE,
|
||||
SKILLS_ROUTE
|
||||
} from '../routes'
|
||||
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
interface PaletteItem {
|
||||
active?: boolean
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
keepOpen?: boolean
|
||||
keywords?: string[]
|
||||
label: string
|
||||
/** Action to run when selected. Mutually exclusive with `to`. */
|
||||
run?: () => void
|
||||
/** Open a nested palette page (VS Code-style "choose X → options"). */
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface PaletteGroup {
|
||||
heading: string
|
||||
items: PaletteItem[]
|
||||
}
|
||||
|
||||
/** A nested page reachable from a root item via `to`. */
|
||||
interface PalettePage {
|
||||
groups: PaletteGroup[]
|
||||
placeholder: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
id: string
|
||||
preview?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
id: session.id,
|
||||
preview: session.preview ?? undefined,
|
||||
title: sessionTitle(session)
|
||||
})
|
||||
|
||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
]
|
||||
|
||||
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
|
||||
{ icon: Sun, label: 'Light', mode: 'light' },
|
||||
{ icon: Moon, label: 'Dark', mode: 'dark' },
|
||||
{ icon: Monitor, label: 'System', mode: 'system' }
|
||||
]
|
||||
|
||||
function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
const raw = configQuery.data?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
|
||||
}, [configQuery.data])
|
||||
|
||||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
|
||||
|
||||
// Reset the query/sub-page on close so it reopens clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
setPage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
|
||||
return [
|
||||
{
|
||||
heading: 'Go to',
|
||||
items: [
|
||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
|
||||
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
|
||||
{
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets', 'providers'],
|
||||
label: 'Skills & Tools',
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
|
||||
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
|
||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
|
||||
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
|
||||
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Command Center',
|
||||
items: [
|
||||
{
|
||||
icon: Archive,
|
||||
id: 'cc-sessions',
|
||||
keywords: ['command center', 'sessions', 'pin'],
|
||||
label: 'Sessions',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
id: 'cc-system',
|
||||
keywords: ['command center', 'system', 'status', 'logs'],
|
||||
label: 'System',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
id: 'cc-usage',
|
||||
keywords: ['command center', 'usage', 'tokens', 'cost'],
|
||||
label: 'Usage',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
icon: Palette,
|
||||
id: 'appearance-theme',
|
||||
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
|
||||
label: 'Change theme…',
|
||||
to: 'theme'
|
||||
},
|
||||
{
|
||||
icon: Sun,
|
||||
id: 'appearance-mode',
|
||||
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
||||
label: 'Change color mode…',
|
||||
to: 'color-mode'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
// navigation entries on an empty palette.
|
||||
const searchGroups = useMemo<PaletteGroup[]>(() => {
|
||||
if (!search.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: PaletteGroup[] = []
|
||||
|
||||
if (sessions.length > 0) {
|
||||
result.push({
|
||||
heading: 'Sessions',
|
||||
items: sessions.map(session => ({
|
||||
icon: MessageCircle,
|
||||
id: `session-${session.id}`,
|
||||
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
label: session.title,
|
||||
run: go(sessionRoute(session.id))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const fieldItems = SECTIONS.flatMap(section =>
|
||||
section.keys.map(key => ({
|
||||
icon: section.icon,
|
||||
id: `field-${key}`,
|
||||
keywords: ['settings', key, section.label],
|
||||
label: `${section.label}: ${fieldLabel(key)}`,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
|
||||
}))
|
||||
)
|
||||
|
||||
result.push({ heading: 'Settings fields', items: fieldItems })
|
||||
|
||||
if (mcpServers.length > 0) {
|
||||
result.push({
|
||||
heading: 'MCP servers',
|
||||
items: mcpServers.map(name => ({
|
||||
icon: Wrench,
|
||||
id: `mcp-${name}`,
|
||||
keywords: ['mcp', 'server', 'tool'],
|
||||
label: name,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (archivedSessions.length > 0) {
|
||||
result.push({
|
||||
heading: 'Archived chats',
|
||||
items: archivedSessions.map(session => ({
|
||||
icon: Archive,
|
||||
id: `archived-${session.id}`,
|
||||
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
label: session.title,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [archivedSessions, go, mcpServers, search, sessions])
|
||||
|
||||
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
|
||||
|
||||
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
|
||||
// and point a root item at it via `to`.
|
||||
const subPages = useMemo<Record<string, PalettePage>>(
|
||||
() => ({
|
||||
theme: {
|
||||
title: 'Theme',
|
||||
placeholder: 'Choose a theme…',
|
||||
// Skins aren't inherently light/dark — the same skin renders in either
|
||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
||||
// once, and keep the palette open so each pick previews live.
|
||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
||||
heading: groupMode === 'light' ? 'Light' : 'Dark',
|
||||
items: availableThemes.map(theme => ({
|
||||
active: themeName === theme.name && resolvedMode === groupMode,
|
||||
icon: groupMode === 'light' ? Sun : Moon,
|
||||
id: `theme-${theme.name}-${groupMode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
||||
label: theme.label,
|
||||
run: () => {
|
||||
setTheme(theme.name)
|
||||
setMode(groupMode)
|
||||
}
|
||||
}))
|
||||
}))
|
||||
},
|
||||
'color-mode': {
|
||||
title: 'Color mode',
|
||||
placeholder: 'Choose color mode…',
|
||||
groups: [
|
||||
{
|
||||
heading: 'Color mode',
|
||||
items: THEME_MODES.map(entry => ({
|
||||
active: mode === entry.mode,
|
||||
icon: entry.icon,
|
||||
id: `mode-${entry.mode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['appearance', 'brightness', entry.label],
|
||||
label: entry.label,
|
||||
run: () => setMode(entry.mode)
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
|
||||
)
|
||||
|
||||
const activePage = page ? subPages[page] : null
|
||||
const visibleGroups = activePage ? activePage.groups : groups
|
||||
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
|
||||
|
||||
const handleSelect = (item: PaletteItem) => {
|
||||
if (item.to) {
|
||||
setPage(item.to)
|
||||
setSearch('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
item.run?.()
|
||||
|
||||
if (!item.keepOpen) {
|
||||
closeCommandPalette()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] 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-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg 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]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setPage(null)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>Back</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
onKeyDown={event => {
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPage(null)
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{visibleGroups.map(group => (
|
||||
<CommandGroup
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
heading={group.heading}
|
||||
key={group.heading}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2.5"
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.to ? (
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -25,12 +26,12 @@ import {
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
@@ -86,23 +87,16 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
}
|
||||
]
|
||||
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
|
||||
enabled: 'default',
|
||||
scheduled: 'default',
|
||||
running: 'default',
|
||||
paused: 'warn',
|
||||
disabled: 'muted',
|
||||
error: 'bad',
|
||||
error: 'destructive',
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
@@ -305,14 +299,13 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
interface CronViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
export function CronView({ onClose }: CronViewProps) {
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
@@ -320,18 +313,16 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load cron jobs')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
@@ -426,29 +417,21 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
<OverlayView closeLabel="Close cron" onClose={onClose}>
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{totalCount > 0 && (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
|
||||
<SearchField
|
||||
containerClassName="max-w-[60vw]"
|
||||
onChange={setQuery}
|
||||
placeholder="Search cron jobs…"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
@@ -463,36 +446,37 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -519,7 +503,7 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -547,14 +531,20 @@ function CronJobRow({
|
||||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<button
|
||||
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
|
||||
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
|
||||
{state}
|
||||
</Badge>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<Badge className="capitalize" variant="muted">
|
||||
{deliver}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
|
||||
@@ -580,13 +570,13 @@ function CronJobRow({
|
||||
onClick={onPauseResume}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
|
||||
<Zap className="size-3.5" />
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
|
||||
<Pencil className="size-3.5" />
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction
|
||||
aria-label="Delete cron"
|
||||
@@ -594,7 +584,7 @@ function CronJobRow({
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</IconAction>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,8 +594,8 @@ function CronJobRow({
|
||||
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon"
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
@@ -614,16 +604,6 @@ function IconAction({ children, className, ...props }: Omit<React.ComponentProps
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
@@ -768,7 +748,7 @@ function CronEditorDialog({
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="cron-frequency" label="Frequency">
|
||||
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
|
||||
<SelectTrigger id="cron-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -783,7 +763,7 @@ function CronEditorDialog({
|
||||
|
||||
<Field htmlFor="cron-deliver" label="Deliver to">
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectTrigger id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { toggleCommandPalette } from '../store/command-palette'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
bumpSessionsLimit,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
PREVIEW_RAIL_PANE_WIDTH
|
||||
} from './chat/right-rail'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
@@ -112,6 +115,7 @@ export function DesktopController() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
@@ -125,9 +129,11 @@ export function DesktopController() {
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
@@ -195,6 +201,31 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
|
||||
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
|
||||
// command center (sessions / system / usage).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'k') {
|
||||
event.preventDefault()
|
||||
toggleCommandPalette()
|
||||
} else if (key === '.') {
|
||||
event.preventDefault()
|
||||
toggleCommandCenter()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [toggleCommandCenter])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
refreshSessionsRequestRef.current = requestId
|
||||
@@ -414,6 +445,8 @@ export function DesktopController() {
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
// Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable.
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
@@ -564,6 +597,7 @@ export function DesktopController() {
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -592,7 +626,6 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -603,6 +636,18 @@ export function DesktopController() {
|
||||
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -641,12 +686,52 @@ export function DesktopController() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||
// browser + preview rail → left. Same panes, swapped sides.
|
||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
const railSide = panesFlipped ? 'left' : 'right'
|
||||
|
||||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
key="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
resizable
|
||||
side={railSide}
|
||||
width={PREVIEW_RAIL_PANE_WIDTH}
|
||||
>
|
||||
{chatOpen ? (
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
) : null}
|
||||
</Pane>
|
||||
)
|
||||
|
||||
const fileBrowserPane = (
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
id="file-browser"
|
||||
key="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side={railSide}
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftStatusbarItems={leftStatusbarItems}
|
||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||
onOpenSearch={() => openCommandCenterSection('sessions')}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
statusbarItems={statusbarItems}
|
||||
@@ -658,7 +743,7 @@ export function DesktopController() {
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
resizable
|
||||
side="left"
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
@@ -691,25 +776,8 @@ export function DesktopController() {
|
||||
}
|
||||
path="artifacts"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="cron"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
path="profiles"
|
||||
/>
|
||||
<Route element={null} path="cron" />
|
||||
<Route element={null} path="profiles" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="command-center" />
|
||||
<Route element={null} path="agents" />
|
||||
@@ -718,35 +786,13 @@ export function DesktopController() {
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
</PaneMain>
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={PREVIEW_RAIL_PANE_WIDTH}
|
||||
>
|
||||
{chatOpen ? (
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
) : null}
|
||||
</Pane>
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
id="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
{/*
|
||||
Order within a side maps to column order. Default (rail on the right):
|
||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
||||
file-browser | preview | main so preview stays adjacent to the chat.
|
||||
*/}
|
||||
{panesFlipped ? fileBrowserPane : previewPane}
|
||||
{panesFlipped ? previewPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Binds the bare `r` key to a refresh action while the calling view is mounted.
|
||||
* Ignored when a modifier is held, the event repeats, or focus is in an
|
||||
* editable field (so typing "r" in a search/input never triggers it).
|
||||
*/
|
||||
export function useRefreshHotkey(onRefresh: () => void, enabled = true) {
|
||||
const ref = useRef(onRefresh)
|
||||
ref.current = onRefresh
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'r' && event.key !== 'R') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
|
||||
if (
|
||||
target?.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
ref.current()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [enabled])
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Responsive horizontal gutter for primary content bodies (settings right side,
|
||||
// skills, artifacts, command center / sessions). Ratio-based so it scales with
|
||||
// the window, but clamped so it never collapses on narrow widths or runs away
|
||||
// on ultrawide displays. Headers/tabs intentionally keep their own tighter
|
||||
// padding.
|
||||
//
|
||||
// NOTE: these must stay literal strings — Tailwind's scanner only picks up
|
||||
// complete class names, so do not build them via template interpolation.
|
||||
export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
|
||||
|
||||
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
|
||||
// out to the gutter edges before re-applying PAGE_INSET_X.
|
||||
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,6 +18,7 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
@@ -41,11 +43,11 @@ const STATE_LABELS: Record<string, string> = {
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
|
||||
good: 'default',
|
||||
muted: 'muted',
|
||||
warn: 'warn',
|
||||
bad: 'destructive'
|
||||
}
|
||||
|
||||
const HINT_BY_STATE: Record<string, string> = {
|
||||
@@ -213,6 +215,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPlatforms()
|
||||
}, [refreshPlatforms])
|
||||
@@ -343,15 +347,15 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchTrailingAction={null}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
@@ -406,8 +410,8 @@ function PlatformRow({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
@@ -482,7 +486,7 @@ function PlatformDetail({
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
Open setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
@@ -560,19 +564,15 @@ function PlatformDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<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 ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${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">Unsaved changes</span>}
|
||||
@@ -651,7 +651,7 @@ function MessagingField({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-9 rounded-lg font-mono text-sm"
|
||||
className="font-mono"
|
||||
id={`messaging-field-${field.key}`}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
@@ -698,27 +698,13 @@ function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<Badge variant={TONE_VARIANT[tone]}>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Search } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
containerClassName?: string
|
||||
inputClassName?: string
|
||||
loading?: boolean
|
||||
onClear?: () => void
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
trailingAction?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
loading = false,
|
||||
onClear,
|
||||
inputRef,
|
||||
trailingAction
|
||||
}: OverlaySearchInputProps) {
|
||||
const clear = onClear ?? (() => onChange(''))
|
||||
const hasTrailing = Boolean(trailingAction)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn(
|
||||
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
|
||||
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||
{trailingAction}
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSearchInput(props: OverlaySearchInputProps) {
|
||||
return (
|
||||
<OverlaySearchInput
|
||||
{...props}
|
||||
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
|
||||
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -43,7 +45,9 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
|
||||
// pt clears the floating titlebar/header; the bg itself fills from the
|
||||
// card's top edge so there's no surface-colored gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -54,7 +58,15 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
|
||||
export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
|
||||
<main
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
PAGE_INSET_X,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,23 +64,26 @@ export function OverlayView({
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
{headerContent && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon"
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
|
||||
{/* No top padding here: the split-layout columns own their own
|
||||
titlebar clearance so their backgrounds run flush to the card top
|
||||
(otherwise the card surface shows as a gap above the sidebar). */}
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PageSearchInput } from './overlays/overlay-search-input'
|
||||
|
||||
interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
children: ReactNode
|
||||
/** Primary tabs shown on the top row, beside the search. */
|
||||
tabs?: ReactNode
|
||||
/** Secondary filters shown full-width on their own row below (expands). */
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
}
|
||||
|
||||
export function PageSearchShell({
|
||||
children,
|
||||
className,
|
||||
tabs,
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
...props
|
||||
}: PageSearchShellProps) {
|
||||
return (
|
||||
@@ -29,29 +33,42 @@ export function PageSearchShell({
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
Header lives in the page body, below the window chrome (the shell floats
|
||||
traffic lights over the top titlebar-height strip, which the `pt` clears
|
||||
and leaves draggable). Top row: primary tabs + search. Second row:
|
||||
secondary filters, full-width so they expand. Interactive bits opt out
|
||||
of the drag region.
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))'
|
||||
}}
|
||||
>
|
||||
<PageSearchInput
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
|
||||
{/*
|
||||
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
|
||||
full width over the band where the floating titlebar icon clusters live,
|
||||
and an overlapping OS drag region eats their clicks at the compositor
|
||||
level (pointer-events / no-drag carve-outs across separate stacking
|
||||
contexts don't reliably fix it on macOS). The shell already supplies a
|
||||
draggable titlebar strip that is `calc()`'d around the icon clusters
|
||||
(see app-shell.tsx), so window dragging still works here.
|
||||
*/}
|
||||
<div className="shrink-0">
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
|
||||
) : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
<SearchField
|
||||
containerClassName="max-w-[45vw]"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filters ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -28,9 +28,9 @@ import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icon
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -40,26 +40,18 @@ function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
interface ProfilesViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
interface ProfilesViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ProfilesView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ProfilesViewProps) {
|
||||
export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
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)
|
||||
@@ -72,33 +64,15 @@ export function ProfilesView({
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load profiles')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('profiles', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <Codicon name="refresh" spinning={refreshing} />,
|
||||
id: 'refresh-profiles',
|
||||
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
|
||||
onSelect: () => void refresh()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('profiles', [])
|
||||
}, [refresh, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!profiles) {
|
||||
return null
|
||||
@@ -164,62 +138,58 @@ export function ProfilesView({
|
||||
}, [pendingDelete, refresh])
|
||||
|
||||
return (
|
||||
<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">Profiles</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
|
||||
</span>
|
||||
</header>
|
||||
<OverlayView closeLabel="Close profiles" onClose={onClose}>
|
||||
{!profiles ? (
|
||||
<PageLoader label="Loading profiles..." />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 pl-1.5 pr-0.5">
|
||||
<span className="text-[0.7rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
Profiles
|
||||
</span>
|
||||
<Button
|
||||
aria-label="New profile"
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</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="Loading profiles..." />
|
||||
) : (
|
||||
<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" />
|
||||
New profile
|
||||
</Button>
|
||||
</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">No profiles yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<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">Select a profile to view its details.</p>
|
||||
</div>
|
||||
<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">Select a profile to view its details.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
@@ -250,7 +220,7 @@ export function ProfilesView({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,8 +228,10 @@ 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',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
@@ -311,38 +283,30 @@ function ProfileDetail({
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{profile.has_env && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
|
||||
.env
|
||||
</span>
|
||||
)}
|
||||
{profile.is_default && <Badge>Default</Badge>}
|
||||
{profile.has_env && <Badge variant="muted">.env</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!profile.is_default && (
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
|
||||
<Pencil />
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
|
||||
<Terminal />
|
||||
{copying ? 'Copying...' : 'Copy setup'}
|
||||
</Button>
|
||||
{!profile.is_default && (
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
className="hover:text-destructive hover:no-underline"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="text"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
@@ -351,7 +315,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="Model">
|
||||
{profile.model ? (
|
||||
<>
|
||||
@@ -387,7 +351,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm text-foreground">{children}</dd>
|
||||
<dd className="text-xs text-foreground">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -458,9 +422,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">
|
||||
Loading SOUL.md...
|
||||
</div>
|
||||
<PageLoader className="min-h-44" label="Loading SOUL.md" />
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-72 font-mono text-xs leading-5"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -121,11 +122,7 @@ export function ProjectTree({
|
||||
}
|
||||
|
||||
function TreeSizingState() {
|
||||
return (
|
||||
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
Loading files...
|
||||
</div>
|
||||
)
|
||||
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
@@ -31,13 +32,14 @@ interface RightSidebarTab {
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', label: 'File system', icon: 'files' },
|
||||
{ id: 'files', label: 'File system', icon: 'list-tree' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
@@ -96,7 +98,12 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
return (
|
||||
<aside
|
||||
aria-label="Right sidebar"
|
||||
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
|
||||
className={cn(
|
||||
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
|
||||
panesFlipped
|
||||
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
@@ -141,23 +148,24 @@ function RightSidebarChrome({
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
|
||||
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
data-active={tab.id === activeTab}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
@@ -178,8 +186,11 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
|
||||
// base focus ring come from <Button size="icon-xs">. This constant exists
|
||||
// purely to share the sidebar palette + the hover-reveal behavior below.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
@@ -213,11 +224,22 @@ function FilesystemTab({
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open folder"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -228,23 +250,12 @@ function FilesystemTab({
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
@@ -264,7 +275,7 @@ function FilesystemTab({
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
|
||||
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
|
||||
@@ -52,6 +52,21 @@ export const APP_ROUTES = [
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
|
||||
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
'profiles',
|
||||
'settings'
|
||||
])
|
||||
|
||||
export function isOverlayView(view: AppView): boolean {
|
||||
return OVERLAY_VIEWS.has(view)
|
||||
}
|
||||
|
||||
export function isNewChatRoute(pathname: string): boolean {
|
||||
return pathname === NEW_CHAT_ROUTE
|
||||
}
|
||||
|
||||
@@ -313,6 +313,7 @@ export function useMessageStream({
|
||||
// commit and the synthetic harness shows longtask counts drop from ~5/5s
|
||||
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
|
||||
const sinceLast = performance.now() - lastFlushAtRef.current
|
||||
|
||||
const runFlush = () => {
|
||||
flushHandleRef.current = null
|
||||
lastFlushAtRef.current = performance.now()
|
||||
@@ -531,7 +532,8 @@ export function useMessageStream({
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -588,7 +590,8 @@ export function useMessageStream({
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -786,6 +789,11 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
// message.complete.
|
||||
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
@@ -806,13 +814,16 @@ export function useMessageStream({
|
||||
)
|
||||
}
|
||||
} else if (event.type === 'clarify.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Surface the clarify tool's overlay. The Python side is blocked on
|
||||
// `clarify.respond`, so without this handler the agent would hang
|
||||
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
|
||||
//
|
||||
// Store the request for whichever session raised it — even a background
|
||||
// one. clarify.request is a one-shot event; if we dropped it for an
|
||||
// unfocused session, that session would block on `clarify.respond`
|
||||
// indefinitely and re-focusing it could never recover (the event is
|
||||
// gone). Parking it per-session lets the user answer once they switch
|
||||
// over; the inline ClarifyTool reads the active session's entry.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
|
||||
@@ -823,6 +834,15 @@ export function useMessageStream({
|
||||
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
if (!isActiveEvent) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -152,7 +152,13 @@ export function useSessionStateCache({
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
|
||||
setSessionAttention(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
setSessionAttention(next.storedSessionId, next.needsInput)
|
||||
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
@@ -160,6 +166,7 @@ export function useSessionStateCache({
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
@@ -111,29 +111,22 @@ export function AboutSettings() {
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || applying || !supported}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking && <Loader2 className="size-3 animate-spin" />}
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
|
||||
@@ -143,12 +136,7 @@ export function AboutSettings() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
@@ -158,7 +146,6 @@ export function AboutSettings() {
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Release notes
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { Check } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { prettyName } from './helpers'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
@@ -51,146 +52,80 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{control && <div className="shrink-0">{control}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const activeTheme = availableThemes.find(t => t.name === themeName)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title="Appearance" />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-8">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
|
||||
<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">Color Mode</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Pick a fixed mode or let Hermes follow your system setting.
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{prettyName(mode)}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
|
||||
const active = mode === 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">{label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description="Pick a fixed mode or let Hermes follow your system setting."
|
||||
title="Color Mode"
|
||||
/>
|
||||
</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">Tool Call Display</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Product hides raw tool payloads; Technical shows full input/output.
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: 'product',
|
||||
label: 'Product',
|
||||
description: 'Human-friendly tool activity with concise summaries.'
|
||||
},
|
||||
{
|
||||
id: 'technical',
|
||||
label: 'Technical',
|
||||
description: 'Include raw tool args/results and low-level details.'
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={
|
||||
[
|
||||
{ id: 'product', label: 'Product' },
|
||||
{ id: 'technical', label: 'Technical' }
|
||||
] as const
|
||||
}
|
||||
] 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>
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description="Product hides raw tool payloads; Technical shows full input/output."
|
||||
title="Tool Call Display"
|
||||
/>
|
||||
</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">Theme</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Desktop palettes only. The selected mode is applied on top.
|
||||
</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<section className="grid gap-3">
|
||||
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
|
||||
<div className="grid gap-x-4 gap-y-5 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)'
|
||||
)}
|
||||
className="group text-left"
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
@@ -198,8 +133,17 @@ export function AppearanceSettings() {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl transition',
|
||||
active
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'opacity-90 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
@@ -208,11 +152,7 @@ export function AppearanceSettings() {
|
||||
{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>
|
||||
)}
|
||||
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -17,10 +18,9 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
@@ -53,8 +53,7 @@ function ConfigField({
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return row(
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
|
||||
<div className="flex items-center justify-end">
|
||||
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
@@ -89,7 +88,7 @@ function ConfigField({
|
||||
if (schema.type === 'number') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const n = raw === '' ? 0 : Number(raw)
|
||||
@@ -108,7 +107,7 @@ function ConfigField({
|
||||
if (schema.type === 'list') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e =>
|
||||
onChange(
|
||||
e.target.value
|
||||
@@ -154,7 +153,7 @@ function ConfigField({
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Not set"
|
||||
value={String(value ?? '')}
|
||||
@@ -165,12 +164,11 @@ function ConfigField({
|
||||
}
|
||||
|
||||
export function ConfigSettings({
|
||||
query,
|
||||
activeSectionId,
|
||||
onConfigSaved,
|
||||
onMainModelChanged,
|
||||
importInputRef
|
||||
}: SearchProps & {
|
||||
}: {
|
||||
activeSectionId: string
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
@@ -265,37 +263,41 @@ export function ConfigSettings({
|
||||
)
|
||||
}, [schema])
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const fields = sectionFields.get(activeSectionId) ?? []
|
||||
|
||||
if (!schema || !q) {
|
||||
return []
|
||||
// Deep-link target from the command palette (?field=<key>): scroll the row
|
||||
// into view and flash it, then drop the param so it doesn't re-fire.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const targetField = searchParams.get('field')
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetField || !config || !schema) {
|
||||
return
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const element = document.getElementById(`setting-field-${targetField}`)
|
||||
|
||||
return SECTIONS.flatMap(s =>
|
||||
s.keys.flatMap(k => {
|
||||
if (seen.has(k) || !schema[k]) {
|
||||
return []
|
||||
}
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(k)
|
||||
const label = prettyName(k.split('.').pop() ?? k)
|
||||
const item = schema[k]
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
element.classList.add('setting-field-highlight')
|
||||
|
||||
const hit =
|
||||
k.toLowerCase().includes(q) ||
|
||||
label.toLowerCase().includes(q) ||
|
||||
includesQuery(item.category, q) ||
|
||||
includesQuery(item.description, q)
|
||||
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
|
||||
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
|
||||
})
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete('field')
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}, [schema, query])
|
||||
|
||||
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [config, schema, setSearchParams, targetField])
|
||||
|
||||
function handleImport(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -325,34 +327,30 @@ export function ConfigSettings({
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && !query.trim() && (
|
||||
{activeSectionId === 'model' && (
|
||||
<div className="mb-6">
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
)}
|
||||
{fields.length === 0 ? (
|
||||
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
|
||||
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="grid gap-1">
|
||||
{fields.map(([key, field]) => (
|
||||
<ConfigField
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
key={key}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ProviderPrefix {
|
||||
}
|
||||
|
||||
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
||||
export const CONTROL_TEXT = 'text-[0.8125rem]'
|
||||
export const CONTROL_TEXT = 'text-xs'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
@@ -289,21 +289,11 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
export interface ModeOption {
|
||||
id: ThemeMode
|
||||
label: string
|
||||
description: string
|
||||
icon: IconComponent
|
||||
}
|
||||
|
||||
export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
{ id: 'light', label: 'Light', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', icon: Moon },
|
||||
{ id: 'system', label: 'System', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...'
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export function GatewaySettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 divide-y divide-border/40">
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
@@ -272,28 +272,30 @@ export function GatewaySettings() {
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Test remote
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
Save for next restart
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Save and reconnect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 divide-y divide-border/40">
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
||||
<FileText className="size-4" />
|
||||
Open logs
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -8,19 +8,18 @@ import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlaySearchInput } from '../overlays/overlay-search-input'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
@@ -34,22 +33,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
about: '',
|
||||
config: '',
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: ''
|
||||
})
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
|
||||
const query = queries[queryKey]
|
||||
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
|
||||
|
||||
const exportConfig = async () => {
|
||||
try {
|
||||
const cfg = await getHermesConfigRecord()
|
||||
@@ -80,35 +65,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
}
|
||||
}
|
||||
|
||||
// OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel="Close settings"
|
||||
headerContent={
|
||||
<OverlaySearchInput
|
||||
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
|
||||
inputRef={searchInputRef}
|
||||
onChange={setQuery}
|
||||
placeholder={SEARCH_PLACEHOLDER[queryKey]}
|
||||
value={query}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<OverlayView closeLabel="Close settings" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -116,7 +74,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
|
||||
return (
|
||||
<OverlayNavItem
|
||||
active={activeView === view && !queries.config.trim()}
|
||||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
@@ -182,7 +140,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="p-0">
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
@@ -195,14 +153,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
importInputRef={importInputRef}
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
query={queries.config}
|
||||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings query={queries.keys} />
|
||||
<KeysSettings />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
<SessionsSettings />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
@@ -10,17 +9,10 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import {
|
||||
asText,
|
||||
includesQuery,
|
||||
prettyName,
|
||||
providerGroup,
|
||||
providerPriority,
|
||||
redactedValue,
|
||||
withoutKey
|
||||
} from './helpers'
|
||||
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
@@ -62,7 +54,7 @@ function EnvActions({
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
<Button onClick={onEdit} size="xs" variant="textStrong">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
@@ -167,8 +159,7 @@ function EnvVarRow({
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<Codicon name="close" />
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,16 +170,24 @@ function EnvVarRow({
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps
|
||||
rowProps,
|
||||
forceExpand = false
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
forceExpand?: boolean
|
||||
}) {
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpand) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [forceExpand])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
@@ -209,7 +208,9 @@ function EnvProviderGroup({
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -217,12 +218,20 @@ function EnvProviderGroup({
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings({ query }: SearchProps) {
|
||||
export function KeysSettings() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
|
||||
// matching provider group, scroll the row in, and flash it.
|
||||
const highlightKey = useDeepLinkHighlight({
|
||||
elementId: key => `env-var-${key}`,
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars?.[key])
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
@@ -253,32 +262,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const entries = Object.entries(vars).filter(([key, info]) =>
|
||||
filterEnv(info, key, q, 'provider', providerGroup(key))
|
||||
)
|
||||
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
@@ -293,15 +282,13 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [filterEnv, query, vars])
|
||||
}, [vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
@@ -310,12 +297,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([key, info]) => filterEnv(info, key, q, cat))
|
||||
.filter(([, info]) => asText(info.category) === cat)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
})
|
||||
}, [filterEnv, query, vars])
|
||||
}, [vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
@@ -407,7 +394,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
|
||||
<EnvProviderGroup
|
||||
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
|
||||
group={group}
|
||||
key={group.name}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,7 +413,9 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
|
||||
import { Package, Wrench } from '@/lib/icons'
|
||||
import { Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { includesQuery } from './helpers'
|
||||
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface McpSettingsProps extends SearchProps {
|
||||
interface McpSettingsProps {
|
||||
gateway?: HermesGateway | null
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
@@ -42,15 +42,7 @@ const transportLabel = (server: Record<string, unknown>) =>
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
|
||||
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
|
||||
}
|
||||
|
||||
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
|
||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
@@ -80,10 +72,13 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
const servers = useMemo(() => getServers(config), [config])
|
||||
const names = useMemo(() => Object.keys(servers).sort(), [servers])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
|
||||
[names, query, servers]
|
||||
)
|
||||
useDeepLinkHighlight({
|
||||
block: 'nearest',
|
||||
elementId: serverName => `mcp-server-${serverName}`,
|
||||
onResolve: setSelected,
|
||||
param: 'server',
|
||||
ready: serverName => Boolean(config) && serverName in servers
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const server = selected ? servers[selected] : null
|
||||
@@ -188,31 +183,32 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
|
||||
<div className="flex items-center gap-2">
|
||||
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
|
||||
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
|
||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-end gap-4">
|
||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||
New server
|
||||
</Button>
|
||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
|
||||
<OverlayCard className="min-h-64 overflow-hidden p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="min-h-64">
|
||||
{names.length === 0 ? (
|
||||
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{filtered.map(serverName => {
|
||||
<div className="grid gap-0.5">
|
||||
{names.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
|
||||
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
className={cn(
|
||||
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
id={`mcp-server-${serverName}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
@@ -227,9 +223,9 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
</div>
|
||||
|
||||
<OverlayCard className="grid gap-3 p-4">
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? 'Edit server' : 'New server'}
|
||||
@@ -249,17 +245,23 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void removeServer(selected)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
Remove
|
||||
</OverlayActionButton>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
|
||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||
{saving ? 'Saving...' : 'Save server'}
|
||||
</OverlayActionButton>
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
@@ -204,11 +204,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
|
||||
title="Main model"
|
||||
/>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
||||
</p>
|
||||
@@ -238,7 +233,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -252,7 +247,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
Reset all to main
|
||||
</Button>
|
||||
@@ -260,7 +255,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
||||
</p>
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="grid gap-1">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
@@ -275,7 +270,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="text"
|
||||
>
|
||||
Set to main
|
||||
</Button>
|
||||
@@ -283,7 +278,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
@@ -292,7 +287,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
|
||||
export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
|
||||
<div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>
|
||||
<div className="mx-auto w-full max-w-4xl">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -16,16 +19,7 @@ export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
|
||||
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <Badge variant={tone === 'primary' ? 'default' : 'muted'}>{children}</Badge>
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
@@ -10,7 +10,7 @@ import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
@@ -30,7 +30,7 @@ function workspaceLabel(cwd: null | string | undefined): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
export function SessionsSettings() {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -87,17 +87,11 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
useDeepLinkHighlight({
|
||||
elementId: id => `archived-session-${id}`,
|
||||
param: 'session',
|
||||
ready: id => !loading && sessions.some(session => session.id === id)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
@@ -117,50 +111,48 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
<div className="grid gap-1">
|
||||
{sessions.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -192,7 +184,10 @@ function DefaultProjectDirSetting() {
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
if (!alive) {
|
||||
return
|
||||
}
|
||||
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
@@ -205,7 +200,9 @@ function DefaultProjectDirSetting() {
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
@@ -229,7 +226,9 @@ function DefaultProjectDirSetting() {
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
@@ -251,13 +250,19 @@ function DefaultProjectDirSetting() {
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void choose()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
@@ -121,7 +122,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
@@ -150,7 +151,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -210,6 +211,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
|
||||
providers.find(p => providerConfigured(p, envState)) ??
|
||||
providers[0]
|
||||
|
||||
setActiveProvider(selected.name)
|
||||
}, [activeProvider, providers, envState, cfg])
|
||||
|
||||
@@ -250,12 +252,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
}, [cfg, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading configuration...
|
||||
</div>
|
||||
)
|
||||
return <PageLoader className="min-h-32" label="Loading configuration" />
|
||||
}
|
||||
|
||||
if (emptyMessage) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
@@ -15,10 +14,6 @@ export interface SettingsPageProps {
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface ProviderGroup {
|
||||
name: string
|
||||
priority: number
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
interface DeepLinkHighlightOptions {
|
||||
param: string
|
||||
ready: (target: string) => boolean
|
||||
elementId: (target: string) => string
|
||||
onResolve?: (target: string) => void
|
||||
block?: ScrollLogicalPosition
|
||||
}
|
||||
|
||||
// Deep-link from the command palette (?<param>=<id>): once the target row is
|
||||
// renderable, scroll it into view and flash it, then drop the param so it
|
||||
// doesn't re-fire. Returns the pending target (null once consumed) so callers
|
||||
// can force the row open before it mounts.
|
||||
export function useDeepLinkHighlight({
|
||||
param,
|
||||
ready,
|
||||
elementId,
|
||||
onResolve,
|
||||
block = 'center'
|
||||
}: DeepLinkHighlightOptions): null | string {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const target = searchParams.get(param)
|
||||
|
||||
useEffect(() => {
|
||||
if (!target || !ready(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
onResolve?.(target)
|
||||
|
||||
// Defer a frame so async state (expansion, selection) mounts the row first.
|
||||
const scrollTimeout = window.setTimeout(() => {
|
||||
const element = document.getElementById(elementId(target))
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
}, 80)
|
||||
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
|
||||
return () => window.clearTimeout(scrollTimeout)
|
||||
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
|
||||
|
||||
return target
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
@@ -20,11 +21,9 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
commandCenterOpen?: boolean
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
@@ -47,17 +46,16 @@ const viewportIsFullscreen = () =>
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
commandCenterOpen = false,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
onOpenSearch,
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
@@ -69,7 +67,12 @@ export function AppShell({
|
||||
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
|
||||
const titlebarContentInset = sidebarOpen
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead.
|
||||
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
@@ -130,13 +133,7 @@ export function AppShell({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onOpenSettings={onOpenSettings}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GatewayMenuPanel({
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
title="Open system panel"
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
const location = useLocation()
|
||||
@@ -15,8 +14,10 @@ export function useOverlayRouting() {
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const agentsOpen = currentView === 'agents'
|
||||
const cronOpen = currentView === 'cron'
|
||||
const profilesOpen = currentView === 'profiles'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const overlayOpen = OVERLAY_VIEWS.has(currentView)
|
||||
const overlayOpen = isOverlayView(currentView)
|
||||
|
||||
// Overlay routes (settings/command-center/agents) stash the underlying path
|
||||
// so closing them returns there instead of bouncing to /.
|
||||
@@ -59,9 +60,11 @@ export function useOverlayRouting() {
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuSubContent
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
@@ -184,24 +183,25 @@ export function ModelEditSubmenu({
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto cursor-pointer"
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
@@ -214,7 +214,7 @@ export function ModelEditSubmenu({
|
||||
>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
key={option.value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={option.value}
|
||||
|
||||
@@ -178,7 +178,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
return (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
@@ -212,7 +212,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
Edit Models…
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
|
||||
const STATUSBAR_ACTION_CLASS =
|
||||
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export interface StatusbarMenuItem {
|
||||
id: string
|
||||
icon?: ReactNode
|
||||
@@ -93,10 +98,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
@@ -167,10 +169,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -183,10 +182,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.to) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,12 +13,18 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
toggleFileBrowserOpen,
|
||||
togglePanesFlipped,
|
||||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
|
||||
import { PROFILES_ROUTE } from '../routes'
|
||||
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
@@ -41,22 +48,16 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
tools?: readonly TitlebarTool[]
|
||||
commandCenterOpen?: boolean
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
tools = [],
|
||||
commandCenterOpen = false,
|
||||
onOpenSettings,
|
||||
onOpenSearch
|
||||
}: TitlebarControlsProps) {
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
@@ -70,38 +71,45 @@ export function TitlebarControls({
|
||||
}
|
||||
}
|
||||
|
||||
// Each titlebar button controls the pane physically on its side, so a flip
|
||||
// swaps which pane each one toggles. Default: sessions left, file browser
|
||||
// right. Flipped: file browser left, sessions right. Sidebar toggles never
|
||||
// carry an active highlight — they're plain show/hide affordances.
|
||||
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
|
||||
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
|
||||
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleSidebarOpen()
|
||||
leftEdge.toggle()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: commandCenterOpen,
|
||||
icon: <Codicon name="search" />,
|
||||
id: 'search',
|
||||
label: 'Search',
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: 'Swap sidebar sides',
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSearch()
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
},
|
||||
title: 'Search sessions, views, and actions'
|
||||
title: 'Swap the sessions and file browser sides'
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
active: fileBrowserOpen,
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
|
||||
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleFileBrowserOpen()
|
||||
rightEdge.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +117,7 @@ export function TitlebarControls({
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
onSelect: toggleHaptics
|
||||
@@ -125,6 +133,14 @@ export function TitlebarControls({
|
||||
}
|
||||
]
|
||||
|
||||
// While a full-screen overlay (settings, command center, …) is open it should
|
||||
// visually own the window. These control clusters are `fixed` at a higher
|
||||
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
|
||||
// and let the overlay's own chrome (close button, drag region) take over.
|
||||
if (isOverlayView(appViewForPath(location.pathname))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
|
||||
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
|
||||
@@ -181,15 +197,20 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
aria-label="Profiles"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
|
||||
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title="Profiles"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="account" />
|
||||
</button>
|
||||
{/* Optical bump: the `account` glyph has more internal padding than
|
||||
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
|
||||
Nudge just this glyph to visually match its neighbours. */}
|
||||
<Codicon name="account" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
|
||||
<DropdownMenuLabel>
|
||||
@@ -214,31 +235,30 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
||||
}
|
||||
|
||||
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
|
||||
const className = cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
|
||||
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
|
||||
tool.className
|
||||
)
|
||||
// Titlebar actions never show an active background — state reads from the
|
||||
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
|
||||
// for a11y.
|
||||
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
|
||||
|
||||
if (tool.href) {
|
||||
return (
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
className={className}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
@@ -251,10 +271,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
tool.onSelect?.()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
||||
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
|
||||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
// Titlebar palette only. All sizing/radius/cursor/centering come from the
|
||||
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
|
||||
// Button is the single source of button styling.
|
||||
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -2,8 +2,7 @@ import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
@@ -11,7 +10,9 @@ import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
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 } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
@@ -72,25 +73,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Skills failed to load')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
@@ -181,65 +179,54 @@ 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')}>
|
||||
Skills
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</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)}>
|
||||
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}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : 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="Try a broader search or different category." title="No skills found" />
|
||||
) : (
|
||||
<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"
|
||||
@@ -265,7 +252,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="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
@@ -273,7 +260,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<div>
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
@@ -287,7 +274,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
@@ -333,14 +320,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
|
||||
function StatusPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
<Badge
|
||||
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,7 @@ export interface ClientSessionState {
|
||||
sawAssistantPayload: boolean
|
||||
pendingBranchGroup: string | null
|
||||
interrupted: boolean
|
||||
/** A blocking clarify prompt is waiting on the user for this session. Drives
|
||||
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
|
||||
needsInput: boolean
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
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 type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
|
||||
@@ -146,11 +147,6 @@ function IdleView({
|
||||
if (!status.supported) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button onClick={onLater} size="sm" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'}
|
||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||
title="Update not available"
|
||||
@@ -176,11 +172,6 @@ function IdleView({
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button onClick={onLater} size="sm" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
body="You’re running the latest version."
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title="You’re all set"
|
||||
@@ -208,11 +199,13 @@ function IdleView({
|
||||
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
||||
{groups.map(group => (
|
||||
<div key={group.id}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
||||
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
|
||||
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
|
||||
{group.items.map(item => (
|
||||
<li className="flex items-start gap-2" key={item}>
|
||||
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
|
||||
<span className="leading-snug">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -222,7 +215,7 @@ function IdleView({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
|
||||
<Button className="font-semibold" onClick={onInstall} size="lg">
|
||||
Update now
|
||||
</Button>
|
||||
<button
|
||||
@@ -267,9 +260,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
<code className="select-all font-mono text-sm text-foreground">
|
||||
<span className="text-muted-foreground">$ </span>
|
||||
@@ -294,7 +287,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
Hermes will pick up the new version next time you launch it.
|
||||
</p>
|
||||
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
|
||||
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
@@ -339,31 +332,22 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
|
||||
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => 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-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-7" />
|
||||
</span>
|
||||
|
||||
<DialogTitle className="text-center text-xl">Update didn’t finish</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
<ErrorState
|
||||
className="px-6 pb-6 pt-7 pr-8"
|
||||
description={
|
||||
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
||||
{message || 'No worries — nothing was lost. You can try again now.'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
<button
|
||||
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didn’t finish</DialogTitle>}
|
||||
>
|
||||
<Button className="font-semibold" onClick={onRetry} size="lg">
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={onDismiss} variant="text">
|
||||
Not now
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
|
||||
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -33,6 +33,23 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
{selected && <span className="size-1.5 rounded-full bg-primary" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
const isPending = props.result === undefined
|
||||
|
||||
@@ -74,6 +91,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const [typing, setTyping] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
@@ -103,7 +121,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
answer
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearClarifyRequest(matchingRequest.requestId)
|
||||
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
|
||||
// The matching tool.complete will land shortly after, swapping this
|
||||
// panel for the ToolFallback view above.
|
||||
} catch (error) {
|
||||
@@ -140,72 +158,49 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleChoiceKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (typing || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(event.key, 10)
|
||||
|
||||
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
|
||||
event.preventDefault()
|
||||
void respond(choices[numeric - 1]!)
|
||||
}
|
||||
},
|
||||
[choices, respond, submitting, typing]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
|
||||
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
)}
|
||||
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span aria-hidden className="arc-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Hermes is asking
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap leading-snug text-foreground">
|
||||
{question || <em className="text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
|
||||
<div className="grid gap-0.5" role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
|
||||
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
|
||||
OPTION_ROW_CLASS,
|
||||
'text-foreground/95 hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => void respond(choice)}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
void respond(choice)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
|
||||
{index + 1}
|
||||
</span>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
|
||||
)}
|
||||
className={cn(OPTION_ROW_CLASS, 'text-muted-foreground hover:bg-accent/40 hover:text-foreground')}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(true)
|
||||
@@ -213,12 +208,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<PencilLine className="size-3" />
|
||||
</span>
|
||||
<RadioDot selected={false} />
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,7 +217,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{(typing || !hasChoices) && (
|
||||
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
|
||||
<Textarea
|
||||
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
|
||||
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
|
||||
disabled={submitting}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
@@ -270,10 +260,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
)}
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
|
||||
<span>1–{choices.length} to pick</span>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
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"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
type="button"
|
||||
|
||||
@@ -430,7 +430,12 @@ function useThreadScrollAnchor({
|
||||
return
|
||||
}
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
// scroll-up during streaming can race with this effect: the wheel
|
||||
// event hasn't fired yet so stickyBottomRef is still true, and the
|
||||
// immediate pinToBottom() would snap the viewport back to bottom
|
||||
// against the user's intent.
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
|
||||
@@ -375,7 +375,9 @@ const ThinkingDisclosure: FC<{
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [isPreview])
|
||||
// Re-run when the disclosure toggles so the observer attaches to the new
|
||||
// DOM after expand/collapse (refs are conditionally rendered on `open`).
|
||||
}, [isPreview, open])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -616,13 +618,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-left" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-right" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
@@ -660,7 +662,7 @@ const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
@@ -803,7 +805,7 @@ const UserMessage: FC<{
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
Restore checkpoint
|
||||
@@ -812,7 +814,7 @@ const UserMessage: FC<{
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
Go forward
|
||||
|
||||
@@ -389,7 +389,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
))}
|
||||
{showRawSearchDrilldown && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rawResult}
|
||||
</pre>
|
||||
|
||||
@@ -14,11 +14,11 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
|
||||
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon size-6 p-1', className)}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
title={tooltip}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function DisclosureRow({
|
||||
// background fill, just the cursor + the affordance caret.
|
||||
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
|
||||
onToggle
|
||||
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!onToggle}
|
||||
|
||||
@@ -142,6 +142,8 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
|
||||
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
|
||||
}
|
||||
|
||||
const WORDMARK = 'HERMES AGENT'
|
||||
|
||||
function resolveCopy(personality?: string, seed?: number): IntroCopy {
|
||||
const personalityKey = normalizeKey(personality)
|
||||
|
||||
@@ -163,15 +165,14 @@ export function Intro({ personality, seed }: IntroProps) {
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<p
|
||||
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
|
||||
}
|
||||
aria-label={WORDMARK}
|
||||
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
<span>{WORDMARK}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
<span aria-hidden="true">{WORDMARK}</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { Input } from '@/components/ui/input'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import {
|
||||
@@ -167,20 +168,20 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<Header />
|
||||
{onboarding.manual ? (
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => closeManualOnboarding()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="grid gap-3 p-5">
|
||||
{onboarding.manual ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => closeManualOnboarding()}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||
</div>
|
||||
@@ -694,9 +695,11 @@ function ConfirmingModelPanel({
|
||||
queryKey: ['onboarding-model-options', flow.providerSlug],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
})
|
||||
|
||||
const providerRow = options.data?.providers?.find(
|
||||
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
|
||||
)
|
||||
|
||||
const price = providerRow?.pricing?.[flow.currentModel]
|
||||
const freeTier = providerRow?.free_tier
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from '@/lib/icons'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
error: Error
|
||||
@@ -53,39 +53,25 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1500] 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>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
|
||||
{error.message || String(error)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
|
||||
<ErrorState
|
||||
className="w-full max-w-[28rem]"
|
||||
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
|
||||
title="Something broke in the interface"
|
||||
>
|
||||
<Button className="font-semibold" onClick={reset} size="lg">
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="text">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
|
||||
variant="text"
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</ErrorState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
@@ -86,7 +87,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
|
||||
{modelOptions.isPending ? (
|
||||
<BrailleSpinner className="mx-auto text-sm" />
|
||||
) : (
|
||||
'No authenticated providers.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
@@ -118,7 +123,6 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
className="cursor-pointer"
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -132,7 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
Details
|
||||
</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Small status/metadata tag. App radius (not a full pill); tones map to the
|
||||
// shared accent/muted/destructive surfaces so badges read consistently.
|
||||
const badgeVariants = cva(
|
||||
'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
destructive: 'bg-destructive/10 text-destructive',
|
||||
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: { variant: 'default' }
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps<typeof badgeVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Badge({ asChild = false, className, variant, ...props }: BadgeProps) {
|
||||
const Comp = asChild ? Slot.Root : 'span'
|
||||
|
||||
return <Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
|
||||
}
|
||||
|
||||
export { badgeVariants }
|
||||
@@ -4,8 +4,11 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Text buttons are square (no radius) and sized by padding + line-height — no
|
||||
// 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-2 rounded-md text-sm 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-4",
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -16,17 +19,24 @@ const buttonVariants = cva(
|
||||
'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',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
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").
|
||||
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
|
||||
// Emphasized inline-text action: bold + always-underlined link. Use for
|
||||
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
|
||||
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
|
||||
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',
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
'icon-lg': 'size-10 rounded-[4px]',
|
||||
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
// Single source of truth for non-composer form-control chrome — Input,
|
||||
// Textarea, and SelectTrigger all consume this. Mirrors `buttonVariants`:
|
||||
// 2.5px radius, 12px text, padding-driven sizing (no fixed heights). The visual
|
||||
// chrome (background, border tint, hover, focus glow, invalid state) comes from
|
||||
// the `desktop-input-chrome` CSS so every control shares one exact look.
|
||||
export const controlVariants = cva(
|
||||
'desktop-input-chrome w-full min-w-0 rounded-[2.5px] border text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'px-2 py-1',
|
||||
default: 'px-2.5 py-1.5',
|
||||
lg: 'px-3 py-2 text-sm leading-5'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export type ControlVariantProps = VariantProps<typeof controlVariants>
|
||||
@@ -110,7 +110,7 @@ function DropdownMenuItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
@@ -131,7 +131,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
@@ -157,7 +157,7 @@ function DropdownMenuRadioItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
@@ -226,7 +226,7 @@ function DropdownMenuSubTrigger({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AlertCircle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Optional actions row/stack rendered below the copy. */
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
description?: ReactNode
|
||||
/** Defaults to a destructive AlertCircle. */
|
||||
icon?: ReactNode
|
||||
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.
|
||||
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>
|
||||
|
||||
{typeof title === 'string' ? (
|
||||
<h2 className="text-center text-xl font-semibold tracking-tight">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
|
||||
{typeof description === 'string' ? (
|
||||
<p className="max-w-prose text-center text-sm leading-5 text-muted-foreground">{description}</p>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children && <div className="grid gap-2">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,19 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
size,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'desktop-input-chrome h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
controlVariants({ size }),
|
||||
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="input"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader2, Search } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchFieldProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
containerClassName?: string
|
||||
inputClassName?: string
|
||||
loading?: boolean
|
||||
onClear?: () => void
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
trailingAction?: ReactNode
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search field used everywhere (sessions sidebar, pages, overlays,
|
||||
* command center, cron). No box — borderless until focus, then an underline.
|
||||
* Width/placement come from `containerClassName`.
|
||||
*/
|
||||
export function SearchField({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
loading = false,
|
||||
onClear,
|
||||
inputRef,
|
||||
trailingAction,
|
||||
'aria-label': ariaLabel
|
||||
}: SearchFieldProps) {
|
||||
const clear = onClear ?? (() => onChange(''))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 border-b border-transparent px-0.5 transition-colors focus-within:border-(--ui-stroke-secondary)',
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
// `field-sizing: content` grows the input to fit the placeholder/typed
|
||||
// text, capped by the container's max-width — no awkward empty space.
|
||||
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
{trailingAction}
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
icon?: IconComponent
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: readonly SegmentedControlOption<T>[]
|
||||
value: T
|
||||
onChange: (id: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped one-row toggle used for small mutually-exclusive choices
|
||||
* (color mode, tool-call display, usage period, etc.). Flat by design —
|
||||
* no per-option borders, just a tinted track with a raised active pill.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{options.map(({ id, label, icon: Icon }) => {
|
||||
const active = value === id
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
|
||||
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => onChange(id)}
|
||||
type="button"
|
||||
>
|
||||
{Icon && <Icon className="size-3" />}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,17 +2,24 @@ import { Select as SelectPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type ControlVariantProps, controlVariants } from '@/components/ui/control'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & ControlVariantProps) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
controlVariants({ size }),
|
||||
'flex items-center justify-between gap-2 whitespace-nowrap data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
data-slot="select-trigger"
|
||||
@@ -66,7 +73,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-xs outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-default data-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-slot="select-item"
|
||||
|
||||
@@ -242,14 +242,14 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('size-7', className)}
|
||||
className={className}
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
onClick={event => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Switch as SwitchPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
const switchVariants = cva(
|
||||
'peer inline-flex shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-5 w-9',
|
||||
xs: 'h-4 w-7'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const switchThumbVariants = cva(
|
||||
'pointer-events-none block rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-background',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'size-4 data-[state=checked]:translate-x-4',
|
||||
xs: 'size-3 data-[state=checked]:translate-x-3.5'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & VariantProps<typeof switchVariants>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary',
|
||||
className
|
||||
)}
|
||||
data-slot="switch"
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
data-slot="switch-thumb"
|
||||
/>
|
||||
<SwitchPrimitive.Root className={cn(switchVariants({ size }), className)} data-slot="switch" {...props}>
|
||||
<SwitchPrimitive.Thumb className={switchThumbVariants({ size })} data-slot="switch-thumb" />
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'desktop-input-chrome min-h-16 w-full rounded-md border px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
className={cn(controlVariants({ size }), 'min-h-16', className)}
|
||||
data-slot="textarea"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,8 @@ export function createClientSessionState(
|
||||
streamId: null,
|
||||
sawAssistantPayload: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false
|
||||
interrupted: false,
|
||||
needsInput: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$clarifyRequest,
|
||||
$clarifyRequests,
|
||||
type ClarifyRequest,
|
||||
clearClarifyRequest,
|
||||
setClarifyRequest
|
||||
} from './clarify'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
function clarify(sessionId: string | null, requestId: string): ClarifyRequest {
|
||||
return {
|
||||
requestId,
|
||||
question: `question-${requestId}`,
|
||||
choices: null,
|
||||
sessionId
|
||||
}
|
||||
}
|
||||
|
||||
describe('clarify store', () => {
|
||||
beforeEach(() => {
|
||||
$clarifyRequests.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$clarifyRequests.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('keeps clarify requests from concurrent sessions independent', () => {
|
||||
setClarifyRequest(clarify('session-a', 'req-a'))
|
||||
setClarifyRequest(clarify('session-b', 'req-b'))
|
||||
|
||||
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a')
|
||||
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
|
||||
})
|
||||
|
||||
it('exposes only the active session via the focus-scoped view', () => {
|
||||
setClarifyRequest(clarify('session-a', 'req-a'))
|
||||
setClarifyRequest(clarify('session-b', 'req-b'))
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
expect($clarifyRequest.get()?.requestId).toBe('req-a')
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
expect($clarifyRequest.get()?.requestId).toBe('req-b')
|
||||
|
||||
$activeSessionId.set('session-c')
|
||||
expect($clarifyRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears only the targeted session, leaving the other pending', () => {
|
||||
setClarifyRequest(clarify('session-a', 'req-a'))
|
||||
setClarifyRequest(clarify('session-b', 'req-b'))
|
||||
|
||||
clearClarifyRequest('req-a', 'session-a')
|
||||
|
||||
expect($clarifyRequests.get()['session-a']).toBeUndefined()
|
||||
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
|
||||
})
|
||||
|
||||
it('ignores a stale clear whose request id no longer matches', () => {
|
||||
setClarifyRequest(clarify('session-a', 'req-a2'))
|
||||
|
||||
clearClarifyRequest('req-a1', 'session-a')
|
||||
|
||||
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a2')
|
||||
})
|
||||
|
||||
it('clears by request id across sessions when no session hint is given', () => {
|
||||
setClarifyRequest(clarify('session-a', 'shared'))
|
||||
setClarifyRequest(clarify('session-b', 'other'))
|
||||
|
||||
clearClarifyRequest('shared')
|
||||
|
||||
expect($clarifyRequests.get()['session-a']).toBeUndefined()
|
||||
expect($clarifyRequests.get()['session-b']?.requestId).toBe('other')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
export interface ClarifyRequest {
|
||||
requestId: string
|
||||
@@ -7,26 +9,61 @@ export interface ClarifyRequest {
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
// Holds the request_id (and metadata) for the most recent in-flight
|
||||
// clarify call. The inline ClarifyTool component (rendered inside the
|
||||
// assistant message stream) reads this to know which request_id to send
|
||||
// back over `clarify.respond`.
|
||||
export const $clarifyRequest = atom<ClarifyRequest | null>(null)
|
||||
// Pending clarify requests keyed by the runtime session id that raised them.
|
||||
// Storing per-session (instead of one shared slot) lets a *background* session
|
||||
// park its clarify request while the user is looking at a different chat, then
|
||||
// resolve it once they switch over — without a second concurrent clarify
|
||||
// clobbering the first. A request with no session id lands under the empty key.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export const $clarifyRequests = atom<Record<string, ClarifyRequest>>({})
|
||||
|
||||
// The clarify request for the currently-viewed session. The inline ClarifyTool
|
||||
// only ever mounts inside the active session's transcript, so it reads this
|
||||
// focus-scoped view rather than reaching into the whole map.
|
||||
export const $clarifyRequest = computed(
|
||||
[$clarifyRequests, $activeSessionId],
|
||||
(requests, activeId) => requests[keyFor(activeId)] ?? null
|
||||
)
|
||||
|
||||
export function setClarifyRequest(request: ClarifyRequest): void {
|
||||
$clarifyRequest.set(request)
|
||||
$clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request })
|
||||
}
|
||||
|
||||
export function clearClarifyRequest(requestId?: string): void {
|
||||
const current = $clarifyRequest.get()
|
||||
export function clearClarifyRequest(requestId?: string, sessionId?: string | null): void {
|
||||
const requests = $clarifyRequests.get()
|
||||
|
||||
// Targeted clear when the caller knows the session (the common path from the
|
||||
// inline ClarifyTool answering its own request).
|
||||
if (sessionId !== undefined) {
|
||||
const key = keyFor(sessionId)
|
||||
const current = requests[key]
|
||||
|
||||
if (!current || (requestId && current.requestId !== requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...requests }
|
||||
delete next[key]
|
||||
$clarifyRequests.set(next)
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestId && current.requestId !== requestId) {
|
||||
return
|
||||
// Fallback with no session hint: drop every entry matching the request id
|
||||
// (or clear all when none is given).
|
||||
const next: Record<string, ClarifyRequest> = {}
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(requests)) {
|
||||
if (requestId && value.requestId !== requestId) {
|
||||
next[key] = value
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
$clarifyRequest.set(null)
|
||||
if (changed) {
|
||||
$clarifyRequests.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
|
||||
export const $commandPaletteOpen = atom(false)
|
||||
|
||||
export function openCommandPalette(): void {
|
||||
$commandPaletteOpen.set(true)
|
||||
}
|
||||
|
||||
export function closeCommandPalette(): void {
|
||||
$commandPaletteOpen.set(false)
|
||||
}
|
||||
|
||||
export function setCommandPaletteOpen(open: boolean): void {
|
||||
$commandPaletteOpen.set(open)
|
||||
}
|
||||
|
||||
export function toggleCommandPalette(): void {
|
||||
$commandPaletteOpen.set(!$commandPaletteOpen.get())
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
|
||||
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
@@ -53,11 +54,15 @@ export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_K
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
|
||||
// When true, the sessions sidebar moves to the right and the file browser +
|
||||
// preview rail move to the left — a mirror of the default layout.
|
||||
export const $panesFlipped = atom(storedBoolean(PANES_FLIPPED_STORAGE_KEY, false))
|
||||
export const $isSidebarResizing = atom(false)
|
||||
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
|
||||
|
||||
export function setSidebarWidth(width: number) {
|
||||
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
|
||||
@@ -76,6 +81,10 @@ export function toggleFileBrowserOpen() {
|
||||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
}
|
||||
|
||||
export function togglePanesFlipped() {
|
||||
$panesFlipped.set(!$panesFlipped.get())
|
||||
}
|
||||
|
||||
export function selectRightRailTab(id: RightRailTabId) {
|
||||
$rightRailActiveTabId.set(id)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { mergeSessionPage, sessionPinId } from './session'
|
||||
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -23,6 +23,34 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
...over
|
||||
})
|
||||
|
||||
describe('setSessionAttention', () => {
|
||||
it('adds and removes a session id without duplicating it', () => {
|
||||
$attentionSessionIds.set([])
|
||||
|
||||
setSessionAttention('s1', true)
|
||||
setSessionAttention('s1', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
setSessionAttention('s2', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
|
||||
|
||||
setSessionAttention('s1', false)
|
||||
expect($attentionSessionIds.get()).toEqual(['s2'])
|
||||
|
||||
$attentionSessionIds.set([])
|
||||
})
|
||||
|
||||
it('ignores empty ids and no-op clears', () => {
|
||||
$attentionSessionIds.set([])
|
||||
|
||||
setSessionAttention(null, true)
|
||||
setSessionAttention(undefined, true)
|
||||
setSessionAttention('', true)
|
||||
setSessionAttention('missing', false)
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionPinId', () => {
|
||||
it('uses the live id when there is no compression lineage', () => {
|
||||
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
|
||||
|
||||
@@ -62,6 +62,7 @@ export function mergeSessionPage(
|
||||
}
|
||||
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
|
||||
const survivors = previous.filter(
|
||||
session =>
|
||||
!incomingIds.has(session.id) &&
|
||||
@@ -164,6 +165,7 @@ function armSessionWatchdog(sessionId: string) {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
@@ -193,24 +195,41 @@ export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
// Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
|
||||
// the same array reference so subscribers don't churn).
|
||||
const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
|
||||
set(current => {
|
||||
const present = current.includes(id)
|
||||
|
||||
if (on) {
|
||||
return present ? current : [...current, id]
|
||||
}
|
||||
|
||||
return present ? current.filter(x => x !== id) : current
|
||||
})
|
||||
|
||||
// Stored session ids with a blocking prompt (clarify) waiting on the user.
|
||||
// Separate from $workingSessionIds: a session can be "working" (turn running)
|
||||
// AND need input. The sidebar row reads this for a persistent indicator that,
|
||||
// unlike a toast, survives window blur / alt-tab.
|
||||
export const $attentionSessionIds = atom<string[]>([])
|
||||
export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
|
||||
|
||||
export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
|
||||
if (sessionId) {
|
||||
toggleMembership(setAttentionSessionIds, sessionId, needsInput)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
setWorkingSessionIds(current => {
|
||||
const alreadyWorking = current.includes(sessionId)
|
||||
toggleMembership(setWorkingSessionIds, sessionId, working)
|
||||
|
||||
if (working) {
|
||||
return alreadyWorking ? current : [...current, sessionId]
|
||||
}
|
||||
|
||||
return alreadyWorking ? current.filter(id => id !== sessionId) : current
|
||||
})
|
||||
|
||||
// Bookend the watchdog: arm it whenever a session enters "working",
|
||||
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
|
||||
// a streaming event will refresh the timer.
|
||||
// Bookend the watchdog: arm on enter, disarm on leave. A later
|
||||
// noteSessionActivity() from a streaming event refreshes the timer.
|
||||
if (working) {
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
|
||||
@@ -425,6 +425,25 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Primitive-level pointer cursor for every interactive control (buttons,
|
||||
selects, menu items, switches, tabs, summaries). Keeps individual
|
||||
components from having to hardcode `cursor-pointer`; explicit cursor
|
||||
utilities (cursor-grab, cursor-default, disabled:cursor-*) still win since
|
||||
they live in the utilities layer. */
|
||||
@layer base {
|
||||
button:not(:disabled):not([aria-disabled='true']),
|
||||
summary,
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
[role='menuitem']:not([aria-disabled='true']),
|
||||
[role='menuitemradio']:not([aria-disabled='true']),
|
||||
[role='menuitemcheckbox']:not([aria-disabled='true']),
|
||||
[role='option']:not([aria-disabled='true']),
|
||||
[role='switch']:not([aria-disabled='true']),
|
||||
[role='tab']:not([aria-disabled='true']) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
[class*='rounded-full'],
|
||||
[class*=':rounded-full'] {
|
||||
@@ -467,6 +486,57 @@
|
||||
--arc-c1: var(--dt-foreground);
|
||||
}
|
||||
|
||||
/* Quest-style "needs you" pulse for a clarify-blocked session's dot —
|
||||
a soft amber glow that breathes so the row draws the eye without a toast. */
|
||||
@keyframes quest-glow {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 0 0.1875rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 70%, transparent),
|
||||
0 0 0.5rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 45%, transparent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.18);
|
||||
box-shadow:
|
||||
0 0 0.3125rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 90%, transparent),
|
||||
0 0 0.875rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 65%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.quest-glow {
|
||||
animation: quest-glow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quest-glow {
|
||||
animation: none;
|
||||
box-shadow:
|
||||
0 0 0.25rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 80%, transparent),
|
||||
0 0 0.625rem color-mix(in srgb, var(--color-amber-500, #f59e0b) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Command-palette deep-link: briefly flash the targeted settings row. */
|
||||
@keyframes setting-field-flash {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--dt-primary, #f59e0b) 22%, transparent);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-field-highlight {
|
||||
animation: setting-field-flash 1.6s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.setting-field-highlight {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.arc-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -498,6 +568,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* No focus rings, anywhere. Kills the native outline plus Tailwind's
|
||||
`focus-visible:ring-*` (a box-shadow driven by --tw-ring-*). Unlayered so it
|
||||
beats the utilities layer without !important on the outline. The composer /
|
||||
.desktop-input-chrome focus glow is untouched — those set `box-shadow`
|
||||
directly rather than through the ring vars. */
|
||||
*:focus,
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
--tw-ring-shadow: 0 0 #0000 !important;
|
||||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
+12
-4
@@ -85,11 +85,12 @@ fi
|
||||
# is a no-op if the dir already exists. (#18482, salvages #18488)
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
# Numeric UID/GID validation: must be digits only, 1000-65534
|
||||
# Numeric UID/GID validation: must be digits only, non-root, 1-65534.
|
||||
# NAS hosts such as Unraid commonly use low non-root IDs (99:100).
|
||||
validate_uid_gid() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
*) [ "$1" -ge 1000 ] && [ "$1" -le 65534 ] ;;
|
||||
*) [ "$1" -ge 1 ] && [ "$1" -le 65534 ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -198,7 +199,7 @@ if [ "$needs_chown" = true ]; then
|
||||
# Hermes-owned subdirs: recursive chown is safe here because these are
|
||||
# created and managed exclusively by hermes (see the s6-setuidgid mkdir
|
||||
# -p block below for the canonical list).
|
||||
for sub in cron sessions logs hooks memories skills skins plans workspace home profiles; do
|
||||
for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing; do
|
||||
if [ -e "$HERMES_HOME/$sub" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/$sub" 2>/dev/null || \
|
||||
echo "[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing"
|
||||
@@ -215,6 +216,10 @@ fi
|
||||
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
|
||||
# is set) and writes to ui-tui/dist/. Without this chown the new
|
||||
# hermes UID can't write the build output (#28851).
|
||||
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
|
||||
# gateway package on first import. After a UID remap those source-owned
|
||||
# paths still belong to the build-time UID (10000) unless repaired here,
|
||||
# producing EACCES for the supervised gateway (#27221).
|
||||
# - node_modules: root-level dependencies (puppeteer, web tooling)
|
||||
# that runtime code may walk/update.
|
||||
# The set mirrors the build-time `chown -R hermes:hermes` line in the
|
||||
@@ -240,6 +245,7 @@ if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
|
||||
chown -R hermes:hermes \
|
||||
"$INSTALL_DIR/.venv" \
|
||||
"$INSTALL_DIR/ui-tui" \
|
||||
"$INSTALL_DIR/gateway" \
|
||||
"$INSTALL_DIR/node_modules" \
|
||||
2>/dev/null || \
|
||||
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
|
||||
@@ -308,7 +314,9 @@ as_hermes mkdir -p \
|
||||
"$HERMES_HOME/skins" \
|
||||
"$HERMES_HOME/plans" \
|
||||
"$HERMES_HOME/workspace" \
|
||||
"$HERMES_HOME/home"
|
||||
"$HERMES_HOME/home" \
|
||||
"$HERMES_HOME/pairing" \
|
||||
"$HERMES_HOME/platforms/pairing"
|
||||
|
||||
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
|
||||
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
|
||||
|
||||
Automates what a user otherwise does by hand: open the Nous Portal
|
||||
``/local-dashboards`` page in a browser, click "register", copy the
|
||||
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
|
||||
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
|
||||
|
||||
This command:
|
||||
1. Resolves a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
|
||||
"run `hermes setup`" hint when the user isn't logged in.
|
||||
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
|
||||
token, which creates a SELF_HOSTED agent client owned by the caller's
|
||||
org and returns the fully-formed ``agent:{id}`` client_id.
|
||||
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
|
||||
4. Prints a post-register hint explaining that the OAuth gate only engages
|
||||
on a non-loopback bind.
|
||||
|
||||
The portal endpoint is the NAS half of this feature (POST
|
||||
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
|
||||
so this client never needs to know the namespace convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
|
||||
# adjective_noun with a space-free underscore join so it drops cleanly into a
|
||||
# label field. There is NO uniqueness constraint on the portal side (the row
|
||||
# id is the key), so collisions are harmless and we don't retry.
|
||||
_NAME_ADJECTIVES = (
|
||||
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
|
||||
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
|
||||
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
|
||||
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
|
||||
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
|
||||
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
|
||||
)
|
||||
|
||||
_NAME_NOUNS = (
|
||||
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
|
||||
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
|
||||
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
|
||||
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
|
||||
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
|
||||
# A couple of scientist surnames in the Docker spirit.
|
||||
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
|
||||
)
|
||||
|
||||
|
||||
def _generate_dashboard_name() -> str:
|
||||
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
|
||||
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
|
||||
|
||||
|
||||
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
|
||||
"""Resolve the portal base URL for the registration request.
|
||||
|
||||
Precedence:
|
||||
1. ``override`` — explicit ``--portal-url`` flag or
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
|
||||
preview/staging portal). NOTE: the access token must be valid at
|
||||
this portal — it's minted by whatever portal you logged into, so an
|
||||
override only works if the token's issuer matches (e.g. you logged
|
||||
into the same staging/preview portal).
|
||||
2. The ``portal_base_url`` stored on the Nous login — this is the
|
||||
portal that issued the token, so it's the correct default target.
|
||||
3. The production default.
|
||||
"""
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.rstrip("/")
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
base = state.get("portal_base_url")
|
||||
if isinstance(base, str) and base.strip():
|
||||
return base.rstrip("/")
|
||||
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _register_self_hosted_client(
|
||||
*,
|
||||
access_token: str,
|
||||
portal_base_url: str,
|
||||
name: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx response or
|
||||
transport failure.
|
||||
"""
|
||||
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
||||
body: dict[str, str] = {"name": name}
|
||||
if custom_redirect_uri:
|
||||
body["custom_redirect_uri"] = custom_redirect_uri
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The endpoint returns structured JSON errors ({error, error_description}).
|
||||
detail = ""
|
||||
try:
|
||||
err_body = json.loads(exc.read().decode())
|
||||
detail = (
|
||||
err_body.get("error_description")
|
||||
or err_body.get("error")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if exc.code == 401:
|
||||
raise RuntimeError(
|
||||
"Nous Portal rejected the access token (401). "
|
||||
"Try `hermes auth login nous` to re-authenticate."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Your account is not permitted to register a self-hosted dashboard."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"Portal returned HTTP {exc.code}"
|
||||
+ (f": {detail}" if detail else "")
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("client_id"):
|
||||
raise RuntimeError("Portal returned an unexpected response (no client_id).")
|
||||
return payload
|
||||
|
||||
|
||||
def _print_post_register_hint(
|
||||
*,
|
||||
client_id: str,
|
||||
portal_base_url: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
wrote_portal_url: bool,
|
||||
) -> None:
|
||||
"""Print the success summary + the gate-engagement caveat."""
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
env_path = get_env_path()
|
||||
print()
|
||||
print(f" Wrote to {env_path}:")
|
||||
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
if wrote_portal_url:
|
||||
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
|
||||
print()
|
||||
print(
|
||||
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
||||
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
|
||||
" without auth, which is fine for your own machine."
|
||||
)
|
||||
print()
|
||||
if custom_redirect_uri:
|
||||
# Derive the host the user registered so the example matches it.
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = urlparse(custom_redirect_uri).hostname or "your-host"
|
||||
except Exception:
|
||||
host = "your-host"
|
||||
print(" To require Nous login on your registered host, run the dashboard")
|
||||
print(f" bound publicly (it must be reachable at https://{host}) and log in")
|
||||
print(" at its /login page.")
|
||||
else:
|
||||
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
|
||||
print(" hermes dashboard --host 0.0.0.0")
|
||||
print(" …then log in at the dashboard's /login page.")
|
||||
print()
|
||||
print(
|
||||
" If the dashboard is already running, restart it to pick up the new env."
|
||||
)
|
||||
print(
|
||||
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args) -> None:
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||
from hermes_cli.config import get_env_value, is_managed, save_env_value
|
||||
|
||||
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
|
||||
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# via buildContainerEnvVars). Registering from inside such a container is a
|
||||
# mistake — and save_env_value refuses to write anyway.
|
||||
if is_managed():
|
||||
print(
|
||||
"✗ `hermes dashboard register` is not available in a managed/hosted "
|
||||
"install.\n"
|
||||
" The dashboard OAuth client is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
|
||||
# with a setup hint when the user isn't logged in.
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Portal override: explicit --portal-url flag wins, else the
|
||||
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
||||
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
||||
"HERMES_DASHBOARD_PORTAL_URL"
|
||||
)
|
||||
portal_base_url = _resolve_portal_base_url(portal_override)
|
||||
|
||||
name = getattr(args, "name", None) or _generate_dashboard_name()
|
||||
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
||||
|
||||
# 2. Register with the portal.
|
||||
try:
|
||||
result = _register_self_hosted_client(
|
||||
access_token=access_token,
|
||||
portal_base_url=portal_base_url,
|
||||
name=name,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Registration failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
client_id = str(result["client_id"])
|
||||
registered_name = str(result.get("name") or name)
|
||||
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
|
||||
# 3. Write env vars idempotently. Always set the client_id. Only set the
|
||||
# portal URL when it isn't already configured (env or config) AND differs
|
||||
# from the production default, so we don't clutter .env for the common case
|
||||
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
|
||||
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
sys.exit(1)
|
||||
|
||||
wrote_portal_url = False
|
||||
default_portal = "https://portal.nousresearch.com"
|
||||
existing_portal = None
|
||||
try:
|
||||
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
||||
except Exception:
|
||||
existing_portal = None
|
||||
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
||||
wrote_portal_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# 4. Hint.
|
||||
_print_post_register_hint(
|
||||
client_id=client_id,
|
||||
portal_base_url=portal_base_url,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
wrote_portal_url=wrote_portal_url,
|
||||
)
|
||||
@@ -11978,6 +11978,13 @@ def cmd_dashboard(args):
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args):
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.dashboard_register import cmd_dashboard_register as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_completion(args, parser=None):
|
||||
"""Print shell completion script."""
|
||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||
@@ -15288,6 +15295,50 @@ Examples:
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# `hermes dashboard register` — register a self-hosted dashboard OAuth
|
||||
# client with Nous Portal and write the client_id into ~/.hermes/.env.
|
||||
# Nested subparser so bare `hermes dashboard` keeps launching the server
|
||||
# (set_defaults(func=cmd_dashboard) above remains the default).
|
||||
dashboard_subparsers = dashboard_parser.add_subparsers(
|
||||
dest="dashboard_subcommand"
|
||||
)
|
||||
dashboard_register_parser = dashboard_subparsers.add_parser(
|
||||
"register",
|
||||
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
|
||||
description=(
|
||||
"Register this install as a self-hosted dashboard with your Nous "
|
||||
"Portal account. Creates an OAuth client, writes "
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
|
||||
"how to engage the login gate. Requires being logged in (hermes setup)."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help="Human-readable label for the dashboard (default: an auto-generated name)",
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--redirect-uri",
|
||||
dest="redirect_uri",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
|
||||
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--portal-url",
|
||||
dest="portal_url",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the Nous Portal base URL for registration (default: the "
|
||||
"portal you logged into). The access token must be valid at this "
|
||||
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
|
||||
"testing against a staging/preview portal."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)
|
||||
|
||||
# =========================================================================
|
||||
# desktop (a.k.a. gui) command
|
||||
#
|
||||
|
||||
@@ -97,16 +97,25 @@ def _install_dependencies(provider_name: str) -> None:
|
||||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||
|
||||
import shutil
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if not uv_path:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
if uv_path:
|
||||
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
|
||||
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
|
||||
else:
|
||||
pip_cmd = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip_cmd:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
print(f" ⚠ uv not found. Falling back to standard pip...")
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
|
||||
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing,
|
||||
install_cmd,
|
||||
check=True, timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -116,10 +125,10 @@ def _install_dependencies(provider_name: str) -> None:
|
||||
stderr = (e.stderr or b"").decode()[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Install failed: {e}")
|
||||
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
|
||||
# Also show external dependencies (non-pip) if any
|
||||
ext_deps = meta.get("external_dependencies", [])
|
||||
|
||||
+31
-27
@@ -616,6 +616,33 @@ class ModelAssignment(BaseModel):
|
||||
base_url: str = ""
|
||||
|
||||
|
||||
def _apply_main_model_assignment(
|
||||
model_cfg: "Any", provider: str, model: str, base_url: str = ""
|
||||
) -> dict:
|
||||
"""Apply a main-slot model assignment to a ``model`` config dict in place.
|
||||
|
||||
Sets ``provider``/``default``, then reconciles ``base_url``: custom/local
|
||||
providers persist the supplied endpoint URL (the runtime resolver reads
|
||||
``model.base_url`` from config and ignores ``OPENAI_BASE_URL``), while every
|
||||
other provider clears any stale URL so the resolver picks that provider's
|
||||
own default endpoint. The hardcoded ``context_length`` override is always
|
||||
dropped since the new model may have a different context window.
|
||||
|
||||
Returns the same dict (coerced to a fresh dict if the input wasn't one) so
|
||||
callers can assign it straight back onto ``cfg["model"]``.
|
||||
"""
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
if provider.strip().lower() == "custom" and base_url.strip():
|
||||
model_cfg["base_url"] = base_url.strip()
|
||||
elif model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
model_cfg.pop("context_length", None)
|
||||
return model_cfg
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
try:
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
@@ -2016,24 +2043,9 @@ async def set_model_assignment(body: ModelAssignment):
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
# Custom/local providers are defined by their endpoint URL, so a
|
||||
# base_url must be persisted here — the runtime resolver reads
|
||||
# model.base_url from config and no longer consults OPENAI_BASE_URL.
|
||||
# For every other provider, clear any stale base_url so the
|
||||
# resolver picks the provider's own default endpoint.
|
||||
if provider.strip().lower() == "custom" and base_url:
|
||||
model_cfg["base_url"] = base_url
|
||||
elif "base_url" in model_cfg and model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
# Also clear hardcoded context_length override — new model may have
|
||||
# a different context window.
|
||||
if "context_length" in model_cfg:
|
||||
model_cfg.pop("context_length", None)
|
||||
model_cfg = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), provider, model, base_url
|
||||
)
|
||||
cfg["model"] = model_cfg
|
||||
|
||||
# When switching the main provider to Nous, mirror the CLI's
|
||||
@@ -6281,15 +6293,7 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
if model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
model_cfg.pop("context_length", None)
|
||||
cfg["model"] = model_cfg
|
||||
cfg["model"] = _apply_main_model_assignment(cfg.get("model", {}), provider, model)
|
||||
save_config(cfg)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"copii.list@gmail.com": "stremtec",
|
||||
"prostoandrei9@gmail.com": "vladkvlchk",
|
||||
"116314616+ThyFriendlyFox@users.noreply.github.com": "ThyFriendlyFox",
|
||||
"liliangjya@gmail.com": "truenorth-lj",
|
||||
|
||||
@@ -11,6 +11,7 @@ pass after the Phase 2 s6 migration. Any drift is a regression.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -25,7 +26,18 @@ pytestmark = pytest.mark.skipif(
|
||||
|
||||
def test_tty_passthrough_to_container(built_image: str) -> None:
|
||||
"""``docker run -t`` must deliver a real TTY to the container process."""
|
||||
probe = "if [ -t 1 ]; then tput cols; else echo NO_TTY; fi"
|
||||
# Emit the probe result behind a unique marker. The container's s6 boot
|
||||
# output (cont-init diagnostics, skills-sync summaries like
|
||||
# "Done: 90 new, 0 updated, ...", the preinit "uid=0 ... egid=0" line)
|
||||
# is written to the SAME PTY stream before this runs, so we must NOT
|
||||
# scan the whole stream for "the first number" — that picks up a stray
|
||||
# 0 from the boot log and flips the assertion (assert 0 > 0) whenever
|
||||
# boot output shifts (e.g. a new bundled dep changes the skills-sync
|
||||
# counts). Parse only the value tagged with our marker.
|
||||
marker = "HERMES_TTY_COLS"
|
||||
probe = (
|
||||
f'if [ -t 1 ]; then echo "{marker}=$(tput cols)"; else echo "{marker}=NO_TTY"; fi'
|
||||
)
|
||||
cmd = (
|
||||
f"docker run --rm -t -e COLUMNS=123 {built_image} "
|
||||
f"sh -c {shlex.quote(probe)}"
|
||||
@@ -34,17 +46,13 @@ def test_tty_passthrough_to_container(built_image: str) -> None:
|
||||
["script", "-qc", cmd, "/dev/null"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
output = r.stdout.strip()
|
||||
assert "NO_TTY" not in output, f"TTY passthrough failed: {output!r}"
|
||||
numeric_lines = [s for s in output.split() if s.strip().isdigit()]
|
||||
assert numeric_lines, f"No numeric width in output: {output!r}"
|
||||
# The container saw a real TTY (``[ -t 1 ]`` was true, so we got a number
|
||||
# rather than ``NO_TTY``) and terminfo resolved ``tput cols``. That is the
|
||||
# passthrough contract this test guards. The reported width itself depends
|
||||
# on the host PTY's window-size ioctl: a ``script(1)``-allocated PTY on a
|
||||
# headless CI runner has a 0x0 window, so ``tput cols`` legitimately prints
|
||||
# ``0`` while still being a TTY. Assert non-negative, not strictly positive.
|
||||
assert int(numeric_lines[0]) >= 0
|
||||
output = r.stdout
|
||||
matches = re.findall(rf"{marker}=(\S+)", output)
|
||||
assert matches, f"No {marker} marker in output: {output!r}"
|
||||
value = matches[-1].strip()
|
||||
assert value != "NO_TTY", f"TTY passthrough failed: {output!r}"
|
||||
assert value.isdigit(), f"Non-numeric column width {value!r} in: {output!r}"
|
||||
assert int(value) > 0
|
||||
|
||||
|
||||
def test_tui_flag_recognized(built_image: str) -> None:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for ``hermes dashboard register``.
|
||||
|
||||
Covers the CLI half of self-hosted dashboard registration:
|
||||
- Docker-style auto-name generation
|
||||
- not-logged-in fast-fail (AuthError with relogin_required)
|
||||
- managed-install refusal
|
||||
- the happy path: POST shape, env-var writes, custom redirect URI
|
||||
- portal-URL write logic (only when non-default and not already set)
|
||||
- portal HTTP error mapping (401/403)
|
||||
|
||||
The portal HTTP call and the Nous token resolution are both mocked — this
|
||||
file proves the CLI wiring + env-write behaviour. The live end-to-end token
|
||||
round-trip against the Vercel preview build is a separate manual step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.dashboard_register as dr
|
||||
|
||||
|
||||
def _ns(**kw):
|
||||
defaults = dict(name=None, redirect_uri=None)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestNameGenerator:
|
||||
def test_shape_is_adjective_underscore_noun(self):
|
||||
for _ in range(50):
|
||||
name = dr._generate_dashboard_name()
|
||||
assert "_" in name
|
||||
adj, _, noun = name.partition("_")
|
||||
assert adj in dr._NAME_ADJECTIVES
|
||||
assert noun in dr._NAME_NOUNS
|
||||
|
||||
|
||||
class TestFastFails:
|
||||
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
err = AuthError("not logged in", provider="nous", relogin_required=True)
|
||||
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
|
||||
), patch("hermes_cli.config.is_managed", return_value=False):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not logged into Nous Portal" in out
|
||||
assert "hermes setup" in out
|
||||
|
||||
def test_managed_install_refuses(self, capsys):
|
||||
with patch("hermes_cli.config.is_managed", return_value=True):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not available in a managed" in out
|
||||
|
||||
|
||||
def _fake_http_ok(payload: dict):
|
||||
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
||||
return cm
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
|
||||
response=None, captured=None):
|
||||
response = response or {
|
||||
"client_id": "agent:selfhost-1",
|
||||
"id": "selfhost-1",
|
||||
"name": "dreamy_tesla",
|
||||
"kind": "SELF_HOSTED",
|
||||
"custom_redirect_uri": None,
|
||||
"created_at": "2026-06-04T12:00:00.000Z",
|
||||
}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
if captured is not None:
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = dict(req.header_items())
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _fake_http_ok(response)
|
||||
|
||||
saved = {}
|
||||
|
||||
def fake_save(key, value):
|
||||
saved[key] = value
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value=portal
|
||||
), patch(
|
||||
"hermes_cli.config.get_env_value", return_value=None
|
||||
), patch(
|
||||
"hermes_cli.config.save_env_value", side_effect=fake_save
|
||||
), patch.object(
|
||||
dr.urllib.request, "urlopen", side_effect=fake_urlopen
|
||||
):
|
||||
dr.cmd_dashboard_register(args)
|
||||
return saved
|
||||
|
||||
def test_writes_client_id_and_posts_generated_name(self, capsys):
|
||||
captured: dict = {}
|
||||
saved = self._run(args=_ns(), captured=captured)
|
||||
|
||||
# POST shape
|
||||
assert captured["url"].endswith("/api/oauth/self-hosted-client")
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
|
||||
assert "name" in captured["body"] and captured["body"]["name"]
|
||||
assert "custom_redirect_uri" not in captured["body"]
|
||||
|
||||
# env write: client_id present, portal URL NOT written (default portal)
|
||||
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
|
||||
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Registered dashboard" in out
|
||||
assert "non-loopback bind" in out # the gate-engagement hint
|
||||
|
||||
def test_explicit_name_is_sent(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(args=_ns(name="my_box"), captured=captured)
|
||||
assert captured["body"]["name"] == "my_box"
|
||||
|
||||
def test_custom_redirect_uri_is_forwarded(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(
|
||||
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
|
||||
captured=captured,
|
||||
)
|
||||
assert (
|
||||
captured["body"]["custom_redirect_uri"]
|
||||
== "https://hermes.example.com/auth/callback"
|
||||
)
|
||||
|
||||
def test_non_default_portal_is_persisted(self, capsys):
|
||||
saved = self._run(
|
||||
args=_ns(),
|
||||
portal="https://nous-account-service-git-feat-x.vercel.app",
|
||||
)
|
||||
assert (
|
||||
saved["HERMES_DASHBOARD_PORTAL_URL"]
|
||||
== "https://nous-account-service-git-feat-x.vercel.app"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalResolution:
|
||||
def test_override_arg_wins(self):
|
||||
assert (
|
||||
dr._resolve_portal_base_url("https://preview.example.com/")
|
||||
== "https://preview.example.com"
|
||||
)
|
||||
|
||||
def test_falls_back_to_stored_login_portal(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(None)
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
def test_blank_override_ignored(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(" ")
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalErrors:
|
||||
def _run_http_error(self, code, body):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs=None,
|
||||
fp=BytesIO(json.dumps(body).encode()),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
|
||||
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
return exc.value.code
|
||||
|
||||
def test_401_maps_to_reauth_message(self, capsys):
|
||||
code = self._run_http_error(401, {"error": "invalid_token"})
|
||||
assert code == 1
|
||||
assert "re-authenticate" in capsys.readouterr().out
|
||||
|
||||
def test_403_surfaces_server_detail(self, capsys):
|
||||
code = self._run_http_error(
|
||||
403, {"error": "access_denied", "error_description": "Not permitted here."}
|
||||
)
|
||||
assert code == 1
|
||||
assert "Not permitted here." in capsys.readouterr().out
|
||||
@@ -48,3 +48,50 @@ class TestMemorySetupProviderRouting:
|
||||
out = capsys.readouterr().out
|
||||
assert "not found" in out
|
||||
assert "hermes memory setup" in out
|
||||
|
||||
|
||||
class TestInstallDependenciesRunner:
|
||||
"""`_install_dependencies` must install via `uv` when present and fall back
|
||||
to standard `pip` when `uv` is unavailable (e.g. slim containers / CI images
|
||||
that don't ship uv) instead of dead-ending with "cannot install"."""
|
||||
|
||||
def _run_with_missing_dep(self, tmp_path, which_side_effect):
|
||||
"""Drive _install_dependencies for a plugin that declares one missing
|
||||
pip dep, capturing the subprocess.run argv (or None if never called)."""
|
||||
import sys
|
||||
|
||||
(tmp_path / "plugin.yaml").write_text(
|
||||
"pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8"
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
captured["cmd"] = cmd
|
||||
return SimpleNamespace()
|
||||
|
||||
with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \
|
||||
patch("shutil.which", side_effect=which_side_effect), \
|
||||
patch("subprocess.run", fake_run):
|
||||
memory_setup._install_dependencies("x")
|
||||
return captured.get("cmd"), sys.executable
|
||||
|
||||
def test_uses_uv_when_available(self, tmp_path):
|
||||
cmd, _ = self._run_with_missing_dep(
|
||||
tmp_path, lambda b: "/usr/bin/uv" if b == "uv" else None
|
||||
)
|
||||
assert cmd is not None
|
||||
assert cmd[:3] == ["/usr/bin/uv", "pip", "install"]
|
||||
|
||||
def test_falls_back_to_pip_when_uv_missing(self, tmp_path, capsys):
|
||||
"""The salvaged behavior (#5954): no uv but pip present -> python -m pip."""
|
||||
cmd, py = self._run_with_missing_dep(
|
||||
tmp_path, lambda b: "/usr/bin/pip3" if b == "pip3" else None
|
||||
)
|
||||
assert cmd is not None
|
||||
assert cmd[:4] == [py, "-m", "pip", "install"]
|
||||
assert "Falling back to standard pip" in capsys.readouterr().out
|
||||
|
||||
def test_aborts_when_neither_uv_nor_pip(self, tmp_path, capsys):
|
||||
cmd, _ = self._run_with_missing_dep(tmp_path, lambda b: None)
|
||||
assert cmd is None # no install attempted
|
||||
assert "cannot install dependencies" in capsys.readouterr().out
|
||||
|
||||
@@ -1047,6 +1047,39 @@ class TestWebServerEndpoints:
|
||||
assert data["ok"] is True
|
||||
assert data.get("gateway_tools", []) == []
|
||||
|
||||
def test_apply_main_model_assignment_base_url_and_context_reconcile(self):
|
||||
"""The shared main-slot assignment helper must persist base_url only for
|
||||
custom providers, clear stale base_url for hosted ones, and always drop
|
||||
a hardcoded context_length override. Both POST /api/model/set and
|
||||
profile-model writes route through this, so the contract is pinned here."""
|
||||
from hermes_cli.web_server import _apply_main_model_assignment
|
||||
|
||||
# Custom + base_url → persisted; stale context_length dropped.
|
||||
out = _apply_main_model_assignment(
|
||||
{"context_length": 8192}, "custom", "llama-3.1-8b", "http://127.0.0.1:8000/v1"
|
||||
)
|
||||
assert out["provider"] == "custom"
|
||||
assert out["default"] == "llama-3.1-8b"
|
||||
assert out["base_url"] == "http://127.0.0.1:8000/v1"
|
||||
assert "context_length" not in out
|
||||
|
||||
# Hosted provider → stale base_url cleared (no base_url supplied).
|
||||
out = _apply_main_model_assignment(
|
||||
{"base_url": "http://127.0.0.1:8000/v1"}, "openrouter", "anthropic/claude-opus-4.8"
|
||||
)
|
||||
assert out["provider"] == "openrouter"
|
||||
assert out["base_url"] == ""
|
||||
|
||||
# Custom WITHOUT a base_url → don't invent one, clear any stale value.
|
||||
out = _apply_main_model_assignment(
|
||||
{"base_url": "http://stale:1/v1"}, "custom", "m"
|
||||
)
|
||||
assert out["base_url"] == ""
|
||||
|
||||
# Non-dict input is coerced to a fresh dict (never raises).
|
||||
out = _apply_main_model_assignment("not-a-dict", "custom", "m", "http://x/v1")
|
||||
assert out == {"provider": "custom", "default": "m", "base_url": "http://x/v1"}
|
||||
|
||||
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
|
||||
"""Model discovery must tolerate the common /v1/models shapes and
|
||||
never raise (so a slightly non-standard local endpoint still works)."""
|
||||
|
||||
@@ -29,11 +29,15 @@ def test_dockerfile_chowns_runtime_node_modules_to_hermes_user() -> None:
|
||||
|
||||
chown_block = "\n".join(chown_lines)
|
||||
|
||||
# both runtime-mutable trees must be passed to the chown command.
|
||||
# Runtime-mutable trees must be passed to the chown command.
|
||||
# /opt/hermes/web is intentionally excluded: it is build-time only,
|
||||
# because HERMES_WEB_DIST points at hermes_cli/web_dist for runtime.
|
||||
for required_path in ("/opt/hermes/ui-tui", "/opt/hermes/node_modules"):
|
||||
for required_path in (
|
||||
"/opt/hermes/ui-tui",
|
||||
"/opt/hermes/node_modules",
|
||||
"/opt/hermes/gateway",
|
||||
):
|
||||
assert required_path in chown_block, (
|
||||
f"{required_path} must be passed to a chown -R hermes:hermes "
|
||||
f"command in the Dockerfile (see #18800)"
|
||||
f"command in the Dockerfile (see #18800, #27221)"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Contract test: stage2-hook repairs ownership of the gateway install tree.
|
||||
|
||||
When HERMES_UID is remapped at container boot, ``usermod -u`` only rewrites
|
||||
files under the hermes user's home directory ($HERMES_HOME == /opt/data).
|
||||
Runtime-writable trees under ``/opt/hermes`` must be explicitly chowned to the
|
||||
new UID before services drop privileges. ``/opt/hermes/gateway`` is one such
|
||||
tree: Python writes ``__pycache__`` beneath the package on first import, which
|
||||
fails with EACCES if the tree still belongs to the build-time UID (10000) after
|
||||
a remap (#27221).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stage2_text() -> str:
|
||||
if not STAGE2_HOOK.exists():
|
||||
pytest.skip("docker/stage2-hook.sh not present in this checkout")
|
||||
return STAGE2_HOOK.read_text()
|
||||
|
||||
|
||||
def _install_dir_chown_block(text: str) -> str:
|
||||
match = re.search(
|
||||
r"(chown -R hermes:hermes \\\n"
|
||||
r"(?:\s+\"\$INSTALL_DIR/[^\"]+\" \\\n)+"
|
||||
r"\s+2>/dev/null \|\| \\\n"
|
||||
r"\s+echo \"\[stage2\] Warning: chown of build trees failed.*?\")",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
assert match, "stage2-hook.sh must repair ownership of runtime-writable install trees"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_uid_remap_chowns_runtime_writable_gateway_tree(stage2_text: str) -> None:
|
||||
block = _install_dir_chown_block(stage2_text)
|
||||
assert '"$INSTALL_DIR/gateway"' in block, (
|
||||
"the build-tree ownership repair must chown $INSTALL_DIR/gateway so the "
|
||||
"gateway runtime can write Python cache artifacts after a UID remap (#27221)"
|
||||
)
|
||||
|
||||
|
||||
def test_install_dir_chown_keeps_existing_runtime_writable_trees(stage2_text: str) -> None:
|
||||
block = _install_dir_chown_block(stage2_text)
|
||||
for required in (
|
||||
'"$INSTALL_DIR/.venv"',
|
||||
'"$INSTALL_DIR/ui-tui"',
|
||||
'"$INSTALL_DIR/node_modules"',
|
||||
):
|
||||
assert required in block
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Regression tests for Docker stage2 UID/GID handling on NAS hosts.
|
||||
|
||||
Unraid commonly runs appdata as nobody:users (99:100). The stage2 hook must
|
||||
accept those non-root numeric IDs and keep legacy/new pairing stores writable
|
||||
after targeted ownership reconciliation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stage2_text() -> str:
|
||||
if not STAGE2_HOOK.exists():
|
||||
pytest.skip("docker/stage2-hook.sh not present in this checkout")
|
||||
return STAGE2_HOOK.read_text()
|
||||
|
||||
|
||||
def _uid_gid_validator(text: str) -> str:
|
||||
marker = "# --- UID/GID remap ---"
|
||||
before_marker = text.split(marker, 1)[0]
|
||||
start = before_marker.index("validate_uid_gid()")
|
||||
return before_marker[start:]
|
||||
|
||||
|
||||
def _validate_uid_gid(text: str, value: str) -> bool:
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
script = _uid_gid_validator(text) + '\nvalidate_uid_gid "$CANDIDATE"\n'
|
||||
proc = subprocess.run(
|
||||
[bash, "-c", script],
|
||||
env={"PATH": os.environ.get("PATH", ""), "CANDIDATE": value},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "99", "100", "1000", "65534"])
|
||||
def test_uid_gid_validator_accepts_non_root_nas_ids(stage2_text: str, value: str) -> None:
|
||||
assert _validate_uid_gid(stage2_text, value), (
|
||||
f"stage2 hook must accept NAS UID/GID {value}; Unraid uses 99:100 (#38070)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "abc", "99x", "65535"])
|
||||
def test_uid_gid_validator_rejects_root_invalid_and_out_of_range(
|
||||
stage2_text: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
assert not _validate_uid_gid(stage2_text, value)
|
||||
|
||||
|
||||
def _targeted_chown_subdirs(text: str) -> list[str]:
|
||||
m = re.search(
|
||||
r"for sub in (?P<items>.*?); do\n\s*if \[ -e \"\$HERMES_HOME/\$sub\" \]",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "stage2-hook.sh must contain the targeted subdir chown loop"
|
||||
return m.group("items").split()
|
||||
|
||||
|
||||
def test_targeted_chown_covers_legacy_and_new_pairing_dirs(stage2_text: str) -> None:
|
||||
subdirs = _targeted_chown_subdirs(stage2_text)
|
||||
assert "pairing" in subdirs
|
||||
assert "platforms/pairing" in subdirs
|
||||
|
||||
|
||||
def test_seeded_directory_list_covers_legacy_and_new_pairing_dirs(stage2_text: str) -> None:
|
||||
seed_block = stage2_text.split("as_hermes mkdir -p \\", 1)[1].split(
|
||||
"# --- Install-method stamp",
|
||||
1,
|
||||
)[0]
|
||||
assert '"$HERMES_HOME/pairing"' in seed_block
|
||||
assert '"$HERMES_HOME/platforms/pairing"' in seed_block
|
||||
Reference in New Issue
Block a user