mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
feat: more ui qa
This commit is contained in:
parent
64ab17182a
commit
c7e6a48bfb
84 changed files with 939 additions and 1120 deletions
|
|
@ -323,7 +323,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
<ChevronDown className="opacity-60" />
|
||||
) : undefined
|
||||
}
|
||||
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
|
||||
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium underline-offset-4 decoration-current/40 hover:underline disabled:no-underline"
|
||||
title={info.model ?? "switch model"}
|
||||
>
|
||||
<span className="truncate">{modelLabel}</span>
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ function InlineContent({
|
|||
href={node.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||
className="text-primary underline underline-offset-4 decoration-current/40 transition-colors"
|
||||
>
|
||||
{node.text}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -510,7 +510,7 @@ export default function AnalyticsPage() {
|
|||
<span className="font-mono">
|
||||
dashboard.show_token_analytics: true
|
||||
</span>{" "}
|
||||
in <a href="/config" className="underline">Config</a>.
|
||||
in <a href="/config" className="underline underline-offset-4 decoration-current/40">Config</a>.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ function EnvVarRow({
|
|||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
|
|
@ -184,7 +184,7 @@ function EnvVarRow({
|
|||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
|
|
@ -217,7 +217,7 @@ function EnvVarRow({
|
|||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
|
|
@ -407,7 +407,7 @@ function ProviderGroupCard({
|
|||
href={keyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
|
|
|
|||
|
|
@ -927,7 +927,7 @@ export default function ModelsPage() {
|
|||
…) and provider retries, so they diverge from your provider
|
||||
bill. Enable{" "}
|
||||
<span className="font-mono">dashboard.show_token_analytics</span>{" "}
|
||||
in <a href="/config" className="underline">Config</a> to
|
||||
in <a href="/config" className="underline underline-offset-4 decoration-current/40">Config</a> to
|
||||
show the local debug estimate anyway.
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ export default function PluginsPage() {
|
|||
{!m.tab?.hidden ? (
|
||||
|
||||
|
||||
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
|
||||
<Link className="ml-3 inline-flex items-center gap-1 underline underline-offset-4 decoration-current/40" to={m.tab.path}>
|
||||
|
||||
|
||||
<ExternalLink className="h-3 w-3 opacity-65" />
|
||||
|
|
|
|||
|
|
@ -617,11 +617,10 @@ function findSystemPython() {
|
|||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
pyExe,
|
||||
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
)
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -36,12 +36,7 @@ function statusGlyph(status: SubagentStatus): ReactNode {
|
|||
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckCircle2
|
||||
aria-label="Done"
|
||||
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
|
||||
/>
|
||||
)
|
||||
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
|
|
@ -65,7 +60,11 @@ function streamGlyph(entry: SubagentStreamEntry): ReactNode {
|
|||
}
|
||||
|
||||
if (entry.kind === 'thinking') {
|
||||
return <span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">…</span>
|
||||
return (
|
||||
<span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">
|
||||
…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" />
|
||||
|
|
@ -103,8 +102,13 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
|||
}
|
||||
|
||||
const fmtDuration = (seconds?: number) => {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
|
@ -113,18 +117,29 @@ const fmtDuration = (seconds?: number) => {
|
|||
}
|
||||
|
||||
const fmtTokens = (value?: number) => {
|
||||
if (!value) return ''
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
if (s < 2) return 'now'
|
||||
if (s < 60) return `${s}s ago`
|
||||
|
||||
if (s < 2) {
|
||||
return 'now'
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return `${s}s ago`
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
|
||||
if (m < 60) {
|
||||
return `${m}m ago`
|
||||
}
|
||||
|
||||
return `${Math.floor(m / 60)}h ago`
|
||||
}
|
||||
|
|
@ -152,12 +167,14 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
|||
|
||||
if (prev && sameShape && closeInTime && uniqueStep) {
|
||||
prev.nodes.push(node)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +197,9 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
|||
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (active <= 0 || typeof window === 'undefined') return
|
||||
if (active <= 0 || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||
|
||||
|
|
@ -261,10 +280,7 @@ function StreamLine({
|
|||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed"
|
||||
ref={enterRef}
|
||||
>
|
||||
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}>
|
||||
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
|
|
@ -283,13 +299,17 @@ function StreamLine({
|
|||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
const durationSeconds =
|
||||
typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed
|
||||
|
||||
const [open, setOpen] = useState(() => running || depth < 2)
|
||||
const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (running) setOpen(true)
|
||||
if (running) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2)
|
||||
|
|
@ -304,11 +324,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')}
|
||||
data-slot="tool-block"
|
||||
ref={enterRef}
|
||||
>
|
||||
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')} data-slot="tool-block" ref={enterRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
|
||||
|
|
@ -374,4 +390,3 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ 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 { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationButton,
|
||||
|
|
@ -18,8 +20,7 @@ import {
|
|||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FileImage, FileText, FolderOpen, Layers3, Link2 } from '@/lib/icons'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
|
@ -364,10 +365,7 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
|||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
...props
|
||||
}: ArtifactsViewProps) {
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -506,39 +504,26 @@ export function ArtifactsView({
|
|||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
|
||||
Images <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
|
||||
Files <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
|
||||
Links <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchValue={query}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
|
|
@ -549,6 +534,7 @@ export function ArtifactsView({
|
|||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
|
|
@ -602,7 +588,7 @@ export function ArtifactsView({
|
|||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -665,34 +651,6 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
|
|||
)
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
active: boolean
|
||||
icon: typeof Layers3
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-7 gap-1.5 rounded-md px-2 text-[length:var(--conversation-caption-font-size)]',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ArtifactImageCardProps {
|
||||
artifact: ArtifactRecord
|
||||
failedImage: boolean
|
||||
|
|
@ -704,7 +662,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) shadow-sm'
|
||||
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
@ -733,7 +691,9 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
<FileImage className="size-3" />
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">{artifact.label}</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
|
||||
{artifact.label}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[0.625rem] text-(--ui-text-tertiary)">{artifact.value}</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -769,7 +729,7 @@ function ArtifactCellAction({
|
|||
if (href) {
|
||||
return (
|
||||
<ExternalLink
|
||||
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 transition-colors hover:text-foreground hover:underline"
|
||||
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"
|
||||
href={href}
|
||||
showExternalIcon={false}
|
||||
title={title}
|
||||
|
|
@ -782,7 +742,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 transition-colors hover:text-foreground hover:underline',
|
||||
'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'
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
@ -825,7 +785,10 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
|||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className={cn('min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)', isLink ? 'font-normal' : 'font-mono')}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
|
||||
isLink ? 'font-normal' : 'font-mono'
|
||||
)}
|
||||
title={artifact.value}
|
||||
>
|
||||
{value}
|
||||
|
|
@ -902,7 +865,7 @@ function ArtifactTable({
|
|||
</thead>
|
||||
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{artifacts.map(artifact => (
|
||||
<tr className="group/artifact transition-colors hover:bg-(--chrome-action-hover)" key={artifact.id}>
|
||||
<tr className="group/artifact" key={artifact.id}>
|
||||
{ARTIFACT_COLUMNS.map(col => {
|
||||
const Cell = col.Cell
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type IconComponent, MessageSquareText } from '@/lib/icons'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
|
|
@ -39,7 +39,10 @@ export function ContextMenu({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground')}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function ComposerControls({
|
|||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ const entryPreview = (entry: QueuedPromptEntry) =>
|
|||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
if (entries.length === 0) return null
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
|
||||
|
|
|
|||
|
|
@ -89,10 +89,7 @@ export function ComposerTriggerPopover({
|
|||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
index === activeIndex && 'bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
|
|
@ -102,9 +99,7 @@ export function ComposerTriggerPopover({
|
|||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
setComposerTerminalSelection,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment
|
||||
removeComposerAttachment,
|
||||
setComposerTerminalSelection
|
||||
} from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ import { Suspense, useMemo, useRef } from 'react'
|
|||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
|
@ -108,7 +109,7 @@ function ChatHeader({
|
|||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-(--ui-bg-quinary) px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-tertiary) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-bg-tertiary) [-webkit-app-region:no-drag]"
|
||||
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]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
|
@ -269,10 +270,11 @@ export function ChatView({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)',
|
||||
'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Backdrop />
|
||||
<ChatHeader
|
||||
activeSessionId={activeSessionId}
|
||||
isRoutedSessionView={isRoutedSessionView}
|
||||
|
|
@ -283,7 +285,7 @@ export function ChatView({
|
|||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--glass-chat-surface-background) contain-[layout_paint]">
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { useStore } from '@nanostores/react'
|
|||
import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function PreviewEmptyState({
|
|||
)}
|
||||
{secondaryAction && (
|
||||
<button
|
||||
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
|
||||
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
|
||||
disabled={secondaryAction.disabled}
|
||||
onClick={secondaryAction.onClick}
|
||||
type="button"
|
||||
|
|
@ -298,7 +298,7 @@ function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: ()
|
|||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ describe('PreviewPane console state', () => {
|
|||
|
||||
const rendered = render(
|
||||
<PreviewPane
|
||||
onClose={vi.fn()}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -31,7 +30,6 @@ type PreviewWebview = HTMLElement & {
|
|||
|
||||
interface PreviewPaneProps {
|
||||
embedded?: boolean
|
||||
onClose: () => void
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
reloadRequest?: number
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
|
|
@ -85,7 +83,7 @@ function PreviewLoadError({
|
|||
body={
|
||||
<>
|
||||
<a
|
||||
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
|
||||
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"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -118,7 +116,6 @@ const TITLEBAR_GROUP_ID = 'preview'
|
|||
|
||||
export function PreviewPane({
|
||||
embedded = false,
|
||||
onClose,
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
setTitlebarToolGroup,
|
||||
|
|
@ -300,35 +297,13 @@ export function PreviewPane({
|
|||
onSelect: toggleDevTools
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <Codicon name="refresh" spinning={loading} />,
|
||||
id: `${TITLEBAR_GROUP_ID}-reload`,
|
||||
label: 'Reload preview',
|
||||
onSelect: reloadPreview
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="close" />,
|
||||
id: `${TITLEBAR_GROUP_ID}-close`,
|
||||
label: 'Close preview',
|
||||
onSelect: onClose
|
||||
}
|
||||
: [])
|
||||
]
|
||||
|
||||
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
|
||||
|
||||
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
|
||||
}, [
|
||||
consoleOpen,
|
||||
consoleState,
|
||||
devtoolsOpen,
|
||||
isWebPreview,
|
||||
loading,
|
||||
onClose,
|
||||
reloadPreview,
|
||||
setTitlebarToolGroup,
|
||||
toggleDevTools
|
||||
])
|
||||
}, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleOpen) {
|
||||
|
|
@ -633,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 transition-colors hover:text-primary hover:underline"
|
||||
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"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
$filePreviewTabs,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
closeActiveRightRailTab,
|
||||
closeRightRail,
|
||||
closeRightRailTab,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
|
@ -79,57 +79,69 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--glass-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<div
|
||||
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const active = tab.id === activeTab.id
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const active = tab.id === activeTab.id
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
|
||||
active
|
||||
? 'bg-(--glass-editor-surface-background) text-foreground [--tab-bg:var(--glass-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--glass-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:[--tab-bg:var(--chrome-action-hover)]'
|
||||
)}
|
||||
key={tab.id}
|
||||
>
|
||||
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={`Close ${tab.label}`}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
title={`Close ${tab.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={`Close ${tab.label}`}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
title={`Close ${tab.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview pane"
|
||||
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
||||
onClick={closeRightRail}
|
||||
title="Close preview pane"
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PreviewPane
|
||||
embedded
|
||||
onClose={closeActiveRightRailTab}
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
|
|
|
|||
|
|
@ -365,31 +365,28 @@ export function CommandCenterView({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const refreshUsage = useCallback(
|
||||
async (days: UsagePeriod) => {
|
||||
const requestId = usageRequestRef.current + 1
|
||||
usageRequestRef.current = requestId
|
||||
setUsageLoading(true)
|
||||
setUsageError('')
|
||||
const refreshUsage = useCallback(async (days: UsagePeriod) => {
|
||||
const requestId = usageRequestRef.current + 1
|
||||
usageRequestRef.current = requestId
|
||||
setUsageLoading(true)
|
||||
setUsageError('')
|
||||
|
||||
try {
|
||||
const response = await getUsageAnalytics(days)
|
||||
try {
|
||||
const response = await getUsageAnalytics(days)
|
||||
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsage(response)
|
||||
}
|
||||
} catch (error) {
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsageError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
} finally {
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsageLoading(false)
|
||||
}
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsage(response)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
} catch (error) {
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsageError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
} finally {
|
||||
if (usageRequestRef.current === requestId) {
|
||||
setUsageLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
|
|
@ -583,7 +580,10 @@ export function CommandCenterView({
|
|||
const beginAuxiliaryEdit = useCallback(
|
||||
(task: string) => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === task)
|
||||
const initialProvider = current?.provider && current.provider !== 'auto' ? current.provider : mainModel?.provider ?? ''
|
||||
|
||||
const initialProvider =
|
||||
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
|
||||
|
||||
const initialModel = current?.model || mainModel?.model || ''
|
||||
setAuxDraft({ provider: initialProvider, model: initialModel })
|
||||
setEditingAuxTask(task)
|
||||
|
|
@ -658,15 +658,7 @@ export function CommandCenterView({
|
|||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={
|
||||
value === 'sessions'
|
||||
? Pin
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'models'
|
||||
? Cpu
|
||||
: BarChart3
|
||||
}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
|
||||
key={value}
|
||||
label={SECTION_LABELS[value]}
|
||||
onClick={() => setSection(value)}
|
||||
|
|
@ -1168,7 +1160,7 @@ function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }
|
|||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No usage in the last {period} days.{' '}
|
||||
<button className="underline" onClick={onRefresh} type="button">
|
||||
<button className="underline underline-offset-4 decoration-current/20" onClick={onRefresh} type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -24,7 +25,6 @@ import {
|
|||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -216,11 +216,23 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
|
|||
return SCHEDULE_OPTIONS.find(option => option.value === 'weekdays') ?? SCHEDULE_OPTIONS[0]
|
||||
}
|
||||
|
||||
if (dayOfMonth === '*' && month === '*' && isIntegerToken(dayOfWeek) && isIntegerToken(minute) && isIntegerToken(hour)) {
|
||||
if (
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
isIntegerToken(dayOfWeek) &&
|
||||
isIntegerToken(minute) &&
|
||||
isIntegerToken(hour)
|
||||
) {
|
||||
return SCHEDULE_OPTIONS.find(option => option.value === 'weekly') ?? SCHEDULE_OPTIONS[0]
|
||||
}
|
||||
|
||||
if (month === '*' && dayOfWeek === '*' && isIntegerToken(dayOfMonth) && isIntegerToken(minute) && isIntegerToken(hour)) {
|
||||
if (
|
||||
month === '*' &&
|
||||
dayOfWeek === '*' &&
|
||||
isIntegerToken(dayOfMonth) &&
|
||||
isIntegerToken(minute) &&
|
||||
isIntegerToken(hour)
|
||||
) {
|
||||
return SCHEDULE_OPTIONS.find(option => option.value === 'monthly') ?? SCHEDULE_OPTIONS[0]
|
||||
}
|
||||
|
||||
|
|
@ -297,10 +309,7 @@ interface CronViewProps extends React.ComponentProps<'section'> {
|
|||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
...props
|
||||
}: CronViewProps) {
|
||||
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
|
@ -475,9 +484,7 @@ export function CronView({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden">
|
||||
{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
|
||||
</div>
|
||||
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
|
|
@ -488,7 +495,8 @@ export function CronView({
|
|||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
This will remove <span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
|
||||
permanently. It will stop firing immediately.
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -586,13 +594,14 @@ function CronJobRow({
|
|||
)
|
||||
}
|
||||
|
||||
function IconAction({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
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" variant="ghost" {...props}>
|
||||
<Button
|
||||
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -600,7 +609,9 @@ function IconAction({
|
|||
|
||||
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])}>
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
|
|
@ -12,7 +13,6 @@ import {
|
|||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -206,10 +206,7 @@ function fieldCopy(field: MessagingEnvVarInfo) {
|
|||
}
|
||||
}
|
||||
|
||||
export function MessagingView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
...props
|
||||
}: MessagingViewProps) {
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -375,42 +372,42 @@ export function MessagingView({
|
|||
<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">
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
<PlatformRow
|
||||
active={selected?.id === platform.id}
|
||||
onSelect={() => setSelectedId(platform.id)}
|
||||
platform={platform}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
<PlatformRow
|
||||
active={selected?.id === platform.id}
|
||||
onSelect={() => setSelectedId(platform.id)}
|
||||
platform={platform}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected && (
|
||||
<PlatformDetail
|
||||
edits={edits[selected.id] || {}}
|
||||
onClear={key => void handleClear(selected, key)}
|
||||
onEdit={(key, value) =>
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[selected.id]: {
|
||||
...(current[selected.id] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected && (
|
||||
<PlatformDetail
|
||||
edits={edits[selected.id] || {}}
|
||||
onClear={key => void handleClear(selected, key)}
|
||||
onEdit={(key, value) =>
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[selected.id]: {
|
||||
...(current[selected.id] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</PageSearchShell>
|
||||
)
|
||||
|
|
@ -429,7 +426,9 @@ function PlatformRow({
|
|||
<button
|
||||
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'
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
|
|
@ -490,7 +489,9 @@ function PlatformDetail({
|
|||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{platform.description}</p>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{platform.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
|
|
@ -511,7 +512,9 @@ function PlatformDetail({
|
|||
|
||||
<section>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{introCopy(platform)}</p>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
|
|
@ -591,7 +594,7 @@ function PlatformDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--glass-chat-surface-background) px-5 py-2.5">
|
||||
<footer className="border-t border-(--ui-stroke-tertiary) 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
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--glass-sidebar-surface-background) px-2.5 py-3',
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ export function OverlayView({
|
|||
// Settings still closes the picker first instead of the underlying overlay.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerHaptic('close')
|
||||
|
|
@ -56,7 +58,7 @@ export function OverlayView({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-surface-background) shadow-md',
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md',
|
||||
rootClassName
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function PageSearchShell({
|
|||
return (
|
||||
<section
|
||||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)', className)}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
<PageSearchInput
|
||||
|
|
@ -37,7 +37,7 @@ export function PageSearchShell({
|
|||
/>
|
||||
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--glass-chat-surface-background)">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -23,7 +24,6 @@ import {
|
|||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -254,15 +254,7 @@ export function ProfilesView({
|
|||
)
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
active,
|
||||
onSelect,
|
||||
profile
|
||||
}: {
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
@ -364,9 +356,7 @@ function ProfileDetail({
|
|||
{profile.model ? (
|
||||
<>
|
||||
<span className="font-mono">{profile.model}</span>
|
||||
{profile.provider && (
|
||||
<span className="text-muted-foreground"> · {profile.provider}</span>
|
||||
)}
|
||||
{profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not set</span>
|
||||
|
|
@ -673,7 +663,8 @@ function RenameProfileDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renaming updates the profile directory and any wrapper scripts in <span className="font-mono">~/.local/bin</span>.
|
||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
||||
<span className="font-mono">~/.local/bin</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -143,8 +143,8 @@ function ProjectTreeRow({
|
|||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
node.isSelected && 'bg-(--ui-bg-tertiary) text-foreground',
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
|
||||
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
draggable={!isPlaceholder}
|
||||
|
|
@ -192,7 +192,11 @@ function ProjectTreeRow({
|
|||
>
|
||||
{isFolder && !isPlaceholder && (
|
||||
<span aria-hidden className="flex w-3 items-center justify-center">
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={node.isOpen ? 'chevron-down' : 'chevron-right'} size="0.75rem" />
|
||||
<Codicon
|
||||
className="text-(--ui-text-tertiary)"
|
||||
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function RightSidebarPane({
|
|||
return (
|
||||
<aside
|
||||
aria-label="Right sidebar"
|
||||
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
|
||||
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)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)"
|
||||
>
|
||||
<RightSidebarChrome activeTab={activeTab} branch={currentBranch} />
|
||||
|
||||
|
|
@ -116,26 +116,28 @@ export function RightSidebarPane({
|
|||
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex h-8 items-center gap-2 border-b border-(--ui-stroke-tertiary) px-3">
|
||||
{RIGHT_SIDEBAR_TABS.map(tab => (
|
||||
<button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'grid size-6 shrink-0 place-items-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-sidebar-accent active:text-sidebar-accent-foreground',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground'
|
||||
)}
|
||||
data-active={tab.id === activeTab}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
|
||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
||||
{RIGHT_SIDEBAR_TABS.map(tab => (
|
||||
<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'
|
||||
)}
|
||||
data-active={tab.id === activeTab}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<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" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
|
|
@ -208,7 +210,7 @@ function FilesystemTab({
|
|||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-3">{children}</div>
|
||||
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
|||
<div className="relative min-h-0 flex-1 px-2 pb-2">
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader className="size-8 text-(--ui-text-tertiary)" pathSteps={180} strokeScale={0.68} type="spiral-search" />
|
||||
<Loader
|
||||
className="size-8 text-(--ui-text-tertiary)"
|
||||
pathSteps={180}
|
||||
strokeScale={0.68}
|
||||
type="spiral-search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selection.trim() && (
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<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>
|
||||
|
|
@ -105,14 +105,16 @@ export function AppearanceSettings() {
|
|||
)}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<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>
|
||||
|
|
@ -160,14 +162,16 @@ export function AppearanceSettings() {
|
|||
</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>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) p-3 shadow-sm">
|
||||
<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>
|
||||
|
|
@ -197,7 +201,9 @@ export function AppearanceSettings() {
|
|||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{theme.label}</div>
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
Brain,
|
||||
Lock,
|
||||
type IconComponent,
|
||||
Lock,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Monitor,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ function ModeCard({
|
|||
<span>{title}</span>
|
||||
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</p>
|
||||
<p className="mt-1.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useCallback, 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 { Codicon } from '@/components/ui/codicon'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getHermesConfigRecord, saveHermesConfig, type HermesGateway } from '@/hermes'
|
||||
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
|
||||
import { Package, Wrench } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { includesQuery } from './helpers'
|
||||
|
|
@ -64,7 +64,10 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
|||
|
||||
getHermesConfigRecord()
|
||||
.then(next => {
|
||||
if (cancelled) return
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(next)
|
||||
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
||||
setSelected(first)
|
||||
|
|
@ -76,6 +79,7 @@ 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]
|
||||
|
|
@ -97,6 +101,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
|||
|
||||
if (!nextName) {
|
||||
notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +117,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
|||
parsed = raw as Record<string, unknown>
|
||||
} catch (err) {
|
||||
notifyError(err, 'Invalid MCP JSON')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +167,7 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
|||
const reloadMcp = async () => {
|
||||
if (!gateway) {
|
||||
notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ export function NavLink({
|
|||
<Button
|
||||
className={cn(
|
||||
'flex min-h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-[length:var(--conversation-text-font-size)] transition',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
|
|
@ -90,7 +92,11 @@ export function ListRow({
|
|||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && <div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{description}</div>}
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function AppShell({
|
|||
tools={titlebarTools}
|
||||
/>
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-(--glass-chat-surface-background) transition-none">
|
||||
<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">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ export function GatewayMenuPanel({
|
|||
const gatewayOpen = gatewayState === 'open'
|
||||
const gatewayConnecting = gatewayState === 'connecting'
|
||||
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
||||
|
||||
const connectionLabel = gatewayOpen
|
||||
? 'Connected'
|
||||
: gatewayConnecting
|
||||
? 'Connecting'
|
||||
: prettyState(gatewayState || 'offline')
|
||||
|
||||
const inferenceLabel = gatewayOpen
|
||||
? inferenceStatus?.ready
|
||||
? 'Inference ready'
|
||||
|
|
@ -54,6 +56,7 @@ export function GatewayMenuPanel({
|
|||
? 'Inference not ready'
|
||||
: 'Checking inference'
|
||||
: 'Disconnected'
|
||||
|
||||
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
|
||||
const recentLogs = logLines.slice(-5)
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ export function useStatusbarItems({
|
|||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
const subagentsRunning = Object.values(subagentsBySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
|
||||
|
||||
const subagentsRunning = Object.values(subagentsBySession).reduce(
|
||||
(sum, items) => sum + activeSubagentCount(items),
|
||||
0
|
||||
)
|
||||
|
||||
return {
|
||||
bgFailed: failed + previewFailed,
|
||||
|
|
@ -275,16 +279,7 @@ export function useStatusbarItems({
|
|||
},
|
||||
versionItem
|
||||
],
|
||||
[
|
||||
busy,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionStartedAt,
|
||||
turnStartedAt,
|
||||
versionItem
|
||||
]
|
||||
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
|
|
|
|||
|
|
@ -2,17 +2,20 @@ import type * as React from 'react'
|
|||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SidebarPanelLabelProps = React.ComponentProps<'span'>
|
||||
interface SidebarPanelLabelProps extends React.ComponentProps<'span'> {
|
||||
dotClassName?: string
|
||||
}
|
||||
|
||||
export function SidebarPanelLabel({ children, className, ...props }: SidebarPanelLabelProps) {
|
||||
export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1.5 text-[0.6875rem] font-medium text-sidebar-foreground/55',
|
||||
'flex min-w-0 items-center gap-2 pl-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--theme-primary)',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} />
|
||||
<span className="min-w-0 truncate leading-none">{children}</span>
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -183,10 +183,7 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Profiles"
|
||||
className={cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4'
|
||||
)}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title="Profiles"
|
||||
type="button"
|
||||
|
|
@ -220,7 +217,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
const className = cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
|
||||
tool.active && 'bg-(--ui-bg-tertiary)! text-foreground!',
|
||||
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
|
||||
tool.className
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ describe('titlebarControlsPosition', () => {
|
|||
})
|
||||
|
||||
it('uses the macOS fallback while the initial window state is unknown', () => {
|
||||
expect(titlebarControlsPosition(undefined).left).toBe(
|
||||
TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X
|
||||
)
|
||||
expect(titlebarControlsPosition(undefined).left).toBe(TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
|||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-bg-tertiary) hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors 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-(--glass-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
'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))]'
|
||||
|
||||
export const titlebarHeaderShadowClass =
|
||||
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--glass-chat-surface-background) after:to-transparent after:content-['']"
|
||||
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"
|
||||
|
||||
export function titlebarControlsPosition(
|
||||
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
|
@ -65,10 +64,7 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
|||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function SkillsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
...props
|
||||
}: SkillsViewProps) {
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -157,31 +153,27 @@ export function SkillsView({
|
|||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
|
||||
<ModeButton
|
||||
active={mode === 'toolsets'}
|
||||
icon={Wrench}
|
||||
onClick={() => setMode('toolsets')}
|
||||
text="Toolsets"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</div>
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
<CategoryButton
|
||||
active={activeCategory === null}
|
||||
count={totalSkills}
|
||||
label="All"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
/>
|
||||
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
{categories.map(category => (
|
||||
<CategoryButton
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
count={category.count}
|
||||
key={category.key}
|
||||
label={prettyName(category.key)}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
/>
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -192,7 +184,7 @@ export function SkillsView({
|
|||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
|
|
@ -205,167 +197,102 @@ export function SkillsView({
|
|||
}
|
||||
searchValue={query}
|
||||
>
|
||||
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : mode === 'skills' ? (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{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)">
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary) sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
key={skill.name}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || 'No description.'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : mode === 'skills' ? (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5 transition-colors hover:bg-(--ui-bg-quinary)" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
key={skill.name}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || 'No description.'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asText(toolset.description) || 'No description.'}
|
||||
</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tools.map(name => (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asText(toolset.description) || 'No description.'}
|
||||
</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tools.map(name => (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
text
|
||||
}: {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
onClick: () => void
|
||||
text: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryButton({
|
||||
active,
|
||||
count,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
active: boolean
|
||||
count: number
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1 rounded-md bg-transparent px-1.5 text-[0.68rem] transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[0.62rem] text-muted-foreground/80 no-underline">{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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)'
|
||||
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -52,13 +52,7 @@ export type CommandDispatchResponse =
|
|||
| SkillCommandDispatchResponse
|
||||
| SendCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId =
|
||||
| 'artifacts'
|
||||
| 'command-center'
|
||||
| 'messaging'
|
||||
| 'new-session'
|
||||
| 'settings'
|
||||
| 'skills'
|
||||
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { useGpuTier } from '@nous-research/ui/hooks/use-gpu-tier'
|
||||
import { Leva, useControls } from 'leva'
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ThemeControls } from './ThemeControls'
|
||||
import { type CSSProperties, useEffect, useState } from 'react'
|
||||
|
||||
const BLEND_MODES = [
|
||||
'normal',
|
||||
|
|
@ -25,43 +22,7 @@ const BLEND_MODES = [
|
|||
|
||||
type BlendMode = (typeof BLEND_MODES)[number]
|
||||
|
||||
function binaryNoiseDataUrl(tile: number, density: number, size: number, color: string): string {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Cap at 1.5x to match the design-language overlay perf work (PR #14):
|
||||
// with `image-rendering: pixelated` there's no visible win above 1.5x, and
|
||||
// a full retina (2x) PNG is ~78% larger to keep resident in compositor memory.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
|
||||
const physTile = Math.round(tile * dpr)
|
||||
const block = Math.max(1, Math.round(size * dpr))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = physTile
|
||||
canvas.height = physTile
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return ''
|
||||
}
|
||||
|
||||
ctx.fillStyle = color
|
||||
|
||||
for (let y = 0; y < physTile; y += block) {
|
||||
for (let x = 0; x < physTile; x += block) {
|
||||
if (Math.random() < density) {
|
||||
ctx.fillRect(x, y, block, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `url("${canvas.toDataURL('image/png')}")`
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
const gpuTier = useGpuTier()
|
||||
const [controlsOpen, setControlsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -94,9 +55,7 @@ export function Backdrop() {
|
|||
|
||||
const shape = useControls(
|
||||
'UI / Shape',
|
||||
{
|
||||
radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' }
|
||||
},
|
||||
{ radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' } },
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
|
|
@ -108,7 +67,7 @@ export function Backdrop() {
|
|||
'Backdrop / Statue',
|
||||
{
|
||||
enabled: { value: true, label: 'on' },
|
||||
opacity: { value: 0.04, min: 0, max: 1, step: 0.005 },
|
||||
opacity: { value: 0.025, min: 0, max: 1, step: 0.005 },
|
||||
blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
invert: { value: true, label: 'invert color' },
|
||||
saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' },
|
||||
|
|
@ -123,55 +82,14 @@ export function Backdrop() {
|
|||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const vignette = useControls(
|
||||
'Backdrop / Vignette',
|
||||
{
|
||||
enabled: { value: true, label: 'on' },
|
||||
opacity: { value: 0.22, min: 0, max: 1, step: 0.01 },
|
||||
blendMode: { value: 'lighten' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
useTheme: { value: true, label: 'use --warm-glow' },
|
||||
color: { value: '#ffbd38', label: 'color (override)' },
|
||||
origin: {
|
||||
value: '0% 0%',
|
||||
options: ['0% 0%', '100% 0%', '50% 0%', '0% 100%', '100% 100%', '50% 50%'],
|
||||
label: 'corner'
|
||||
},
|
||||
transparentStop: { value: 60, min: 0, max: 100, step: 1, label: 'fade start %' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const noise = useControls(
|
||||
'Backdrop / Noise',
|
||||
{
|
||||
enabled: { value: false, label: 'on' },
|
||||
opacity: { value: 0.21, min: 0, max: 1.5, step: 0.01, label: 'opacity (× mul)' },
|
||||
blendMode: { value: 'color-dodge' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
color: { value: '#eaeaea', label: 'dot color' },
|
||||
density: { value: 0.11, min: 0, max: 1, step: 0.005, label: 'density' },
|
||||
size: { value: 1, min: 1, max: 10, step: 1, label: 'block px' },
|
||||
tile: { value: 256, min: 64, max: 1024, step: 32, label: 'tile px' },
|
||||
reroll: { value: 0, min: 0, max: 100, step: 1, label: 'reroll' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const noiseUrl = useMemo(
|
||||
() => binaryNoiseDataUrl(noise.tile, noise.density, noise.size, noise.color),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[noise.tile, noise.density, noise.size, noise.color, noise.reroll]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />
|
||||
|
||||
{import.meta.env.DEV && <ThemeControls />}
|
||||
|
||||
{statue.enabled && gpuTier > 0 && (
|
||||
{statue.enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-2"
|
||||
className="pointer-events-none absolute inset-0 z-2"
|
||||
style={{
|
||||
mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: statue.opacity
|
||||
|
|
@ -190,33 +108,6 @@ export function Backdrop() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vignette.enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-99"
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at ${vignette.origin}, transparent ${vignette.transparentStop}%, ${vignette.useTheme ? 'var(--warm-glow)' : vignette.color} 100%)`,
|
||||
mixBlendMode: vignette.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: vignette.opacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noise.enabled && gpuTier > 0 && noiseUrl && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-101"
|
||||
style={{
|
||||
backgroundImage: noiseUrl,
|
||||
backgroundSize: `${noise.tile}px ${noise.tile}px`,
|
||||
backgroundRepeat: 'repeat',
|
||||
imageRendering: 'pixelated',
|
||||
mixBlendMode: noise.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: `calc(${noise.opacity} * var(--noise-opacity-mul, 1))`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
/**
|
||||
* Leva-driven palette fine-tuning, dev-mode only.
|
||||
*
|
||||
* Two folders (`Theme / Light` and `Theme / Dark`) expose the seed colors
|
||||
* and mix percentages that drive the glass token derivation. Edits are live
|
||||
* only; use them to tune values before copying them back into presets/CSS.
|
||||
*/
|
||||
|
||||
import { button, useControls } from 'leva'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
|
||||
interface ThemeTuningValues {
|
||||
accentFill: number
|
||||
accentSoft: string
|
||||
backgroundSeed: string
|
||||
bubbleMix: number
|
||||
bubbleSeed: string
|
||||
cardMix: number
|
||||
cardSeed: string
|
||||
chromeMix: number
|
||||
elevatedMix: number
|
||||
elevatedSeed: string
|
||||
foreground: string
|
||||
midground: string
|
||||
primary: string
|
||||
primaryFill: number
|
||||
primaryStroke: number
|
||||
quaternaryFill: number
|
||||
quaternaryStroke: number
|
||||
quinaryFill: number
|
||||
secondary: string
|
||||
secondaryFill: number
|
||||
secondaryStroke: number
|
||||
sidebarMix: number
|
||||
sidebarSeed: string
|
||||
tertiaryFill: number
|
||||
tertiaryStroke: number
|
||||
warm: string
|
||||
}
|
||||
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i
|
||||
|
||||
const swatch = (value: string | undefined) =>
|
||||
typeof value === 'string' && HEX_RE.test(value.trim()) ? value : '#444444'
|
||||
|
||||
const pct = (value: number) => `${value}%`
|
||||
|
||||
const defaultsFor = (mode: 'light' | 'dark') => ({
|
||||
bubbleMix: mode === 'dark' ? 48 : 30,
|
||||
cardMix: mode === 'dark' ? 38 : 22,
|
||||
chromeMix: mode === 'dark' ? 36 : 44,
|
||||
elevatedMix: mode === 'dark' ? 46 : 28,
|
||||
primaryFill: 16,
|
||||
primaryStroke: 24,
|
||||
quaternaryFill: 5,
|
||||
quaternaryStroke: 6,
|
||||
quinaryFill: 3,
|
||||
secondaryFill: 11,
|
||||
secondaryStroke: 16,
|
||||
sidebarMix: mode === 'dark' ? 42 : 36,
|
||||
tertiaryFill: 8,
|
||||
tertiaryStroke: 10
|
||||
})
|
||||
|
||||
const setCss = (name: string, value: string) => document.documentElement.style.setProperty(name, value)
|
||||
|
||||
function applyTuning(values: ThemeTuningValues) {
|
||||
setCss('--theme-foreground', values.foreground)
|
||||
setCss('--theme-primary', values.primary)
|
||||
setCss('--theme-secondary', values.secondary)
|
||||
setCss('--theme-accent-soft', values.accentSoft)
|
||||
setCss('--theme-midground', values.midground)
|
||||
setCss('--theme-warm', values.warm)
|
||||
setCss('--theme-background-seed', values.backgroundSeed)
|
||||
setCss('--theme-sidebar-seed', values.sidebarSeed)
|
||||
setCss('--theme-card-seed', values.cardSeed)
|
||||
setCss('--theme-elevated-seed', values.elevatedSeed)
|
||||
setCss('--theme-bubble-seed', values.bubbleSeed)
|
||||
setCss('--theme-mix-chrome', pct(values.chromeMix))
|
||||
setCss('--theme-mix-sidebar', pct(values.sidebarMix))
|
||||
setCss('--theme-mix-card', pct(values.cardMix))
|
||||
setCss('--theme-mix-elevated', pct(values.elevatedMix))
|
||||
setCss('--theme-mix-bubble', pct(values.bubbleMix))
|
||||
setCss('--theme-fill-primary-accent-mix', pct(values.primaryFill))
|
||||
setCss('--theme-fill-secondary-accent-mix', pct(values.secondaryFill))
|
||||
setCss('--theme-fill-tertiary-accent-mix', pct(values.tertiaryFill))
|
||||
setCss('--theme-fill-quaternary-accent-mix', pct(values.quaternaryFill))
|
||||
setCss('--theme-fill-quinary-accent-mix', pct(values.quinaryFill))
|
||||
setCss('--theme-stroke-primary-accent-mix', pct(values.primaryStroke))
|
||||
setCss('--theme-stroke-secondary-accent-mix', pct(values.secondaryStroke))
|
||||
setCss('--theme-stroke-tertiary-accent-mix', pct(values.tertiaryStroke))
|
||||
setCss('--theme-stroke-quaternary-accent-mix', pct(values.quaternaryStroke))
|
||||
}
|
||||
|
||||
function buildSchema(skinName: string, mode: 'light' | 'dark') {
|
||||
const base = getBaseColors(skinName, mode)
|
||||
const mix = defaultsFor(mode)
|
||||
|
||||
const schema = {
|
||||
foreground: { value: swatch(base.foreground), label: 'text base' },
|
||||
primary: { value: swatch(base.primary), label: 'primary' },
|
||||
secondary: { value: swatch(base.secondary), label: 'secondary' },
|
||||
accentSoft: { value: swatch(base.accent), label: 'accent soft' },
|
||||
midground: { value: swatch(base.midground ?? base.ring), label: 'midground' },
|
||||
warm: { value: swatch(base.primary), label: 'warm glow' },
|
||||
backgroundSeed: { value: swatch(base.background), label: 'chrome seed' },
|
||||
sidebarSeed: { value: swatch(base.sidebarBackground ?? base.background), label: 'sidebar seed' },
|
||||
cardSeed: { value: swatch(base.card), label: 'card seed' },
|
||||
elevatedSeed: { value: swatch(base.popover), label: 'elevated seed' },
|
||||
bubbleSeed: { value: swatch(base.userBubble ?? base.popover), label: 'bubble seed' },
|
||||
chromeMix: { value: mix.chromeMix, min: 0, max: 100, step: 1, label: 'chrome mix %' },
|
||||
sidebarMix: { value: mix.sidebarMix, min: 0, max: 100, step: 1, label: 'sidebar mix %' },
|
||||
cardMix: { value: mix.cardMix, min: 0, max: 100, step: 1, label: 'card mix %' },
|
||||
elevatedMix: { value: mix.elevatedMix, min: 0, max: 100, step: 1, label: 'elevated mix %' },
|
||||
bubbleMix: { value: mix.bubbleMix, min: 0, max: 100, step: 1, label: 'bubble mix %' },
|
||||
primaryFill: { value: mix.primaryFill, min: 0, max: 40, step: 1, label: 'fill primary %' },
|
||||
secondaryFill: { value: mix.secondaryFill, min: 0, max: 40, step: 1, label: 'fill secondary %' },
|
||||
tertiaryFill: { value: mix.tertiaryFill, min: 0, max: 40, step: 1, label: 'fill tertiary %' },
|
||||
quaternaryFill: { value: mix.quaternaryFill, min: 0, max: 40, step: 1, label: 'fill quaternary %' },
|
||||
quinaryFill: { value: mix.quinaryFill, min: 0, max: 40, step: 1, label: 'fill quinary %' },
|
||||
primaryStroke: { value: mix.primaryStroke, min: 0, max: 50, step: 1, label: 'stroke primary %' },
|
||||
secondaryStroke: { value: mix.secondaryStroke, min: 0, max: 50, step: 1, label: 'stroke secondary %' },
|
||||
tertiaryStroke: { value: mix.tertiaryStroke, min: 0, max: 50, step: 1, label: 'stroke tertiary %' },
|
||||
quaternaryStroke: { value: mix.quaternaryStroke, min: 0, max: 50, step: 1, label: 'stroke quaternary %' }
|
||||
}
|
||||
|
||||
return {
|
||||
...schema,
|
||||
'apply defaults': button(() => applyTuning(valuesFromSchema(schema)))
|
||||
} as Parameters<typeof useControls>[1]
|
||||
}
|
||||
|
||||
function valuesFromSchema(schema: Record<string, { value: number | string }>): ThemeTuningValues {
|
||||
return Object.fromEntries(Object.entries(schema).map(([key, field]) => [key, field.value])) as unknown as ThemeTuningValues
|
||||
}
|
||||
|
||||
/** Renders nothing — Leva's UI is a portal driven by `useControls`. */
|
||||
export function ThemeControls() {
|
||||
const { resolvedMode, themeName } = useTheme()
|
||||
const light = useMemo(() => buildSchema(themeName, 'light'), [themeName])
|
||||
const dark = useMemo(() => buildSchema(themeName, 'dark'), [themeName])
|
||||
const lightValues = useControls('Theme / Light', light, { collapsed: resolvedMode !== 'light' }, [themeName])
|
||||
const darkValues = useControls('Theme / Dark', dark, { collapsed: resolvedMode !== 'dark' }, [themeName])
|
||||
|
||||
useEffect(() => {
|
||||
applyTuning((resolvedMode === 'light' ? lightValues : darkValues) as ThemeTuningValues)
|
||||
}, [darkValues, lightValues, resolvedMode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -273,7 +273,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
|
||||
<span>1–{choices.length} to pick</span>
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ async function mediaSrc(path: string): Promise<string> {
|
|||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
return (
|
||||
<button
|
||||
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
className="mt-2 bg-transparent text-xs font-medium text-muted-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
|
||||
onClick={() => void window.hermesDesktop?.openExternal(mediaExternalUrl(path))}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -146,7 +146,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
|
||||
return (
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere"
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -189,7 +189,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'font-semibold text-foreground underline underline-offset-4 decoration-current wrap-anywhere',
|
||||
'font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere',
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
|
|
@ -266,8 +266,12 @@ const MarkdownTextImpl = () => {
|
|||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn('my-1 gap-0', className)} {...props} />,
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn('my-1 gap-0', className)} {...props} />,
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
||||
<ul className={cn('my-1 gap-0', className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
||||
<ol className={cn('my-1 gap-0', className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
||||
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ export function todosFromMessageContent(content: unknown): TodoItem[] {
|
|||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {
|
||||
|
|
@ -70,6 +72,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
|||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -834,9 +834,17 @@ export function inlineDiffFromResult(result: unknown): string {
|
|||
// Falls back to a string only when there's something concrete to render —
|
||||
// counts of opaque items/fields are noise, not signal.
|
||||
function minimalValueSummary(value: unknown): string {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
|
@ -1193,6 +1201,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
|||
|
||||
const searchHits =
|
||||
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -60,8 +60,7 @@ const ToolEmbedContext = createContext(false)
|
|||
const TOOL_HEADER_TITLE_CLASS =
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
|
||||
|
||||
const TOOL_HEADER_DURATION_CLASS =
|
||||
'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
|
||||
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
|
||||
|
||||
const TOOL_HEADER_SUBTITLE_CLASS =
|
||||
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
|
||||
|
|
@ -71,8 +70,7 @@ const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center
|
|||
// Glass-style section label that sits above any pre/JSON/output block.
|
||||
// Lowercase tracking + tiny size so it reads as a quiet field label rather
|
||||
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
|
||||
const TOOL_SECTION_LABEL_CLASS =
|
||||
'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
|
||||
const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
|
||||
|
||||
// Inset scroll surface for any detail body. The expanded tool row owns the
|
||||
// border; the payload itself is just clipped raw text.
|
||||
|
|
@ -138,7 +136,9 @@ function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
|
|||
// Which status (if any) should pre-empt the tool's icon in the leading
|
||||
// slot. Success is silent — the row reads as "done" without a checkmark.
|
||||
function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined {
|
||||
if (isPending) return 'running'
|
||||
if (isPending) {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
return status === 'success' ? undefined : status
|
||||
}
|
||||
|
|
@ -162,9 +162,7 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
|
|||
) : (
|
||||
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
|
||||
)}
|
||||
{hit.snippet && (
|
||||
<p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>
|
||||
)}
|
||||
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
@ -303,9 +301,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
>
|
||||
{view.title}
|
||||
</FadeText>
|
||||
{!isPending && view.countLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>
|
||||
)}
|
||||
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
|
||||
{!isPending && view.durationLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
|
||||
)}
|
||||
|
|
@ -324,9 +320,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
)}
|
||||
{hasSearchHits && view.searchHits && (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{searchResultsLabel && (
|
||||
<p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>
|
||||
)}
|
||||
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
|
||||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -354,22 +348,15 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.detail}
|
||||
</pre>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
|
||||
) : (
|
||||
<CompactMarkdown
|
||||
className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')}
|
||||
text={view.detail}
|
||||
/>
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{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, 'cursor-pointer mb-0')}>Raw response</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rawResult}
|
||||
</pre>
|
||||
|
|
@ -424,6 +411,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
if (!p || typeof p !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const row = p as { toolName?: unknown; type?: unknown }
|
||||
|
||||
return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
|
||||
|
|
@ -484,9 +472,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
>
|
||||
{groupTitle(visibleParts)}
|
||||
</FadeText>
|
||||
{totalDurationLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>
|
||||
)}
|
||||
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
|
||||
</span>
|
||||
{statusSummary && (
|
||||
<FadeText
|
||||
|
|
@ -529,4 +515,3 @@ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: To
|
|||
|
||||
return <ToolEntry part={part} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ function startedAt(key?: string): number {
|
|||
if (!key) {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
const existing = startedAtByKey.get(key)
|
||||
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
startedAtByKey.set(key, now)
|
||||
|
||||
|
|
|
|||
|
|
@ -58,11 +58,7 @@ function CodeCardIcon({ className, ...props }: CodiconProps) {
|
|||
|
||||
function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('font-normal text-muted-foreground', className)}
|
||||
data-slot="code-card-subtitle"
|
||||
{...props}
|
||||
/>
|
||||
<span className={cn('font-normal text-muted-foreground', className)} data-slot="code-card-subtitle" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,14 +41,18 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
|
|||
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
|
||||
if (!href || !/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a className={cn('font-medium underline underline-offset-4 decoration-current', className)} href={href} {...rest}>
|
||||
<a
|
||||
className={cn('font-medium underline underline-offset-4 decoration-current/20', className)}
|
||||
href={href}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExternalLink className={cn('decoration-current', className)} href={href} showExternalIcon={false}>
|
||||
<ExternalLink className={cn('decoration-current/20', className)} href={href} showExternalIcon={false}>
|
||||
{children}
|
||||
<ExternalLinkIcon />
|
||||
</ExternalLink>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ interface DiffLineKind {
|
|||
}
|
||||
|
||||
const DIFF_LINE_KINDS: DiffLineKind[] = [
|
||||
{ className: 'text-emerald-700 dark:text-emerald-300', match: line => line.startsWith('+') && !line.startsWith('+++') },
|
||||
{
|
||||
className: 'text-emerald-700 dark:text-emerald-300',
|
||||
match: line => line.startsWith('+') && !line.startsWith('+++')
|
||||
},
|
||||
{ className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') },
|
||||
{ className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ export function DisclosureRow({
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
{trailing && <span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>}
|
||||
{trailing && (
|
||||
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
|||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--glass-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) shadow-sm">
|
||||
<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">
|
||||
<Header />
|
||||
<div className="grid gap-5 p-6">
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
|
|
@ -209,7 +209,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--glass-chat-bubble-background) px-6 py-5">
|
||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-6 py-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
||||
<Sparkles className="size-5" />
|
||||
|
|
@ -252,7 +252,7 @@ function FooterLink({ children, onClick }: { children: React.ReactNode; onClick:
|
|||
return (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
className="text-sm font-semibold text-foreground underline-offset-4 hover:underline"
|
||||
className="text-sm font-semibold text-foreground underline-offset-4 decoration-current/20 hover:underline"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type IconComponent } from '@/lib/icons'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ export function Pane({
|
|||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--glass-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ 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 hover:underline'
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ function DialogContent({
|
|||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--glass-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
|
@ -100,7 +100,10 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
|
|||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-description"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,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-bg-tertiary) 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 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!",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
|
@ -73,7 +73,7 @@ function DropdownMenuCheckboxItem({
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) 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 cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 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"
|
||||
|
|
@ -101,7 +101,7 @@ function DropdownMenuRadioItem({
|
|||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-bg-tertiary) 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 cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 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"
|
||||
|
|
@ -169,7 +169,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-bg-tertiary) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-bg-tertiary) 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-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)",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function SheetContent({
|
|||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--glass-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
'fixed z-50 flex flex-col gap-3 border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) text-[length:var(--conversation-text-font-size)] shadow-md transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
|
|
@ -97,7 +97,10 @@ function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPr
|
|||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
className={cn('text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)', className)}
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)',
|
||||
className
|
||||
)}
|
||||
data-slot="sheet-description"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
43
apps/desktop/src/components/ui/text-tab.tsx
Normal file
43
apps/desktop/src/components/ui/text-tab.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TextTabMeta({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span className={cn('text-[0.72em] font-normal text-(--ui-text-tertiary)', className)} {...props} />
|
||||
}
|
||||
|
||||
interface TextTabProps extends React.ComponentProps<'button'> {
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function TextTab({ active = false, children, className, type = 'button', ...props }: TextTabProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group/text-tab inline-flex h-7 items-center gap-1 bg-transparent px-1 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
active && 'text-foreground',
|
||||
className
|
||||
)}
|
||||
data-active={active}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, child =>
|
||||
React.isValidElement(child) && child.type === TextTabMeta ? (
|
||||
child
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'underline-offset-4 decoration-current/25',
|
||||
active ? 'underline' : 'group-hover/text-tab:underline'
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { TextTab, TextTabMeta }
|
||||
|
|
@ -394,9 +394,7 @@ export function getProfiles(): Promise<ProfilesResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
export function createProfile(
|
||||
body: ProfileCreatePayload
|
||||
): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
|
||||
path: '/api/profiles',
|
||||
method: 'POST',
|
||||
|
|
@ -404,10 +402,7 @@ export function createProfile(
|
|||
})
|
||||
}
|
||||
|
||||
export function renameProfile(
|
||||
name: string,
|
||||
newName: string
|
||||
): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
|
||||
return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}`,
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
|
|
@ -318,7 +318,11 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
role,
|
||||
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
|
||||
createdAt,
|
||||
status: message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
status: message.error
|
||||
? { type: 'incomplete', reason: 'error', error: message.error }
|
||||
: message.pending
|
||||
? { type: 'running' }
|
||||
: { type: 'complete', reason: 'stop' },
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ describe('external link helpers', () => {
|
|||
it('ignores error-like fetched titles and falls back to slug label', async () => {
|
||||
const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error')
|
||||
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
|
||||
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ export function ExternalLink({
|
|||
|
||||
return (
|
||||
<a
|
||||
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current', className)}
|
||||
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -180,9 +180,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
|||
return () =>
|
||||
function transform(tree: Root, file: VFile): undefined {
|
||||
visitParents(tree, 'element', (element, parents) => {
|
||||
const classes = Array.isArray(element.properties?.className)
|
||||
? (element.properties.className as string[])
|
||||
: []
|
||||
const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : []
|
||||
|
||||
// Match the same class set rehype-katex looks for. `language-math`
|
||||
// is the markdown ` ```math ` form, `math-inline` is what
|
||||
|
|
@ -201,12 +199,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
|||
|
||||
// For ` ```math ` the scope walks up to the wrapping <pre> and
|
||||
// we treat it as display math. Same logic rehype-katex uses.
|
||||
if (
|
||||
languageMath &&
|
||||
parent &&
|
||||
parent.type === 'element' &&
|
||||
(parent as Element).tagName === 'pre'
|
||||
) {
|
||||
if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') {
|
||||
scope = parent as Element
|
||||
parent = parents[parents.length - 2]
|
||||
displayMode = true
|
||||
|
|
@ -253,10 +246,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
|||
* wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`.
|
||||
*/
|
||||
export function createMemoizedMathPlugin(config: MathPluginConfig = {}) {
|
||||
const remarkPlugin: Pluggable = [
|
||||
remarkMath,
|
||||
{ singleDollarTextMath: config.singleDollarTextMath ?? false }
|
||||
]
|
||||
const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }]
|
||||
|
||||
const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor })
|
||||
|
||||
|
|
|
|||
|
|
@ -310,7 +310,9 @@ const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g
|
|||
const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g
|
||||
|
||||
function rewriteLatexBracketDelimiters(text: string): string {
|
||||
return text.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`).replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
|
||||
return text
|
||||
.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`)
|
||||
.replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
|
||||
}
|
||||
|
||||
// Escape `$<digit>` patterns so they don't get eaten as math delimiters.
|
||||
|
|
@ -340,14 +342,19 @@ export function preprocessMarkdown(text: string): string {
|
|||
.split(CODE_FENCE_SPLIT_RE)
|
||||
.map(part => {
|
||||
// Fence blocks pass through untouched.
|
||||
if (/^(?:```|~~~)/.test(part)) {return part}
|
||||
if (/^(?:```|~~~)/.test(part)) {
|
||||
return part
|
||||
}
|
||||
|
||||
// Whitespace-only segments (e.g. the `\n\n` between two adjacent
|
||||
// fences) must NOT go through stripPreviewTargets — its internal
|
||||
// .trim() would collapse them to '' and glue the surrounding
|
||||
// fences together, producing things like ``````math which the
|
||||
// markdown parser then reads as a single 6-backtick block.
|
||||
if (!part.trim()) {return part}
|
||||
if (!part.trim()) {
|
||||
return part
|
||||
}
|
||||
|
||||
// Preserve leading/trailing whitespace around the prose body so
|
||||
// that fence-prose-fence sequences keep their blank-line gaps.
|
||||
// stripPreviewTargets internally calls .trim() on its result for
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import { isProviderSetupErrorMessage } from './provider-setup-errors'
|
|||
|
||||
describe('isProviderSetupErrorMessage', () => {
|
||||
it('matches generic missing-provider copy', () => {
|
||||
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(true)
|
||||
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(
|
||||
true
|
||||
)
|
||||
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
|
||||
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match non-provider runtime failures', () => {
|
||||
expect(
|
||||
isProviderSetupErrorMessage(
|
||||
'Selected runtime is not available. setup.status reports configured credentials.'
|
||||
)
|
||||
isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function parseArray(value: unknown[]): TodoItem[] {
|
|||
if (!isRecord(item) || !isStatus(item.status)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const id = String(item.id ?? '').trim()
|
||||
const content = String(item.content ?? '').trim()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// mode still gets the raw JSON section.
|
||||
|
||||
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
|
||||
|
||||
const PRIORITY_KEYS = [
|
||||
'title',
|
||||
'name',
|
||||
|
|
@ -17,6 +18,7 @@ const PRIORITY_KEYS = [
|
|||
'summary',
|
||||
'description'
|
||||
] as const
|
||||
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
|
|
@ -66,6 +68,7 @@ function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
|
|||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lines = t.split('\n')
|
||||
let text = lines.slice(0, maxLines).join('\n')
|
||||
const clipped = lines.length > maxLines || text.length > maxChars
|
||||
|
|
@ -187,11 +190,13 @@ function formatFieldValue(value: unknown, depth: number): string {
|
|||
if (!v.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const scalars = v.map(summarizeScalar).filter(Boolean)
|
||||
|
||||
if (scalars.length === v.length && v.length <= 4) {
|
||||
return clipInline(scalars.join(', '))
|
||||
}
|
||||
|
||||
const first = summarizeListItem(v[0], depth + 1)
|
||||
|
||||
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
|
||||
|
|
@ -207,16 +212,21 @@ function formatFieldValue(value: unknown, depth: number): string {
|
|||
// "Returned N items" / "0 items" / "Returned an empty object" are all
|
||||
// noise — better to render nothing and let the title carry the signal.
|
||||
function formatArraySummary(value: unknown[], depth: number): string {
|
||||
if (!value.length) return ''
|
||||
if (!value.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const max = 6
|
||||
|
||||
const lines = value
|
||||
.slice(0, max)
|
||||
.map(item => summarizeListItem(item, depth + 1))
|
||||
.filter(Boolean)
|
||||
.map(l => `- ${l}`)
|
||||
|
||||
if (!lines.length) return ''
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value.length > max) {
|
||||
const remaining = value.length - max
|
||||
|
|
@ -228,7 +238,10 @@ function formatArraySummary(value: unknown[], depth: number): string {
|
|||
|
||||
function formatRecordSummary(record: Json, depth: number): string {
|
||||
const keys = Object.keys(record)
|
||||
if (!keys.length) return ''
|
||||
|
||||
if (!keys.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (depth <= 2) {
|
||||
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
|
||||
|
|
@ -249,6 +262,7 @@ function formatRecordSummary(record: Json, depth: number): string {
|
|||
if (!v) {
|
||||
continue
|
||||
}
|
||||
|
||||
lines.push(`- ${titleCase(k)}: ${v}`)
|
||||
|
||||
if (lines.length >= max) {
|
||||
|
|
@ -256,7 +270,9 @@ function formatRecordSummary(record: Json, depth: number): string {
|
|||
}
|
||||
}
|
||||
|
||||
if (!lines.length) return ''
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (candidates.length > lines.length) {
|
||||
const remaining = candidates.length - lines.length
|
||||
|
|
@ -270,6 +286,7 @@ function formatSummaryValue(value: unknown, depth: number): string {
|
|||
if (depth > 4) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {
|
||||
|
|
@ -383,11 +400,13 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
|||
if (depth > 5) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const v = norm(value)
|
||||
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
seen.add(v)
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
|
|
@ -408,6 +427,7 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
|||
if (!hasMeaningfulErrorValue(record[k])) {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = valueErrorText(record[k])
|
||||
|
||||
if (text) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ type QueueState = Record<string, QueuedPromptEntry[]>
|
|||
const STORAGE_KEY = 'hermes.desktop.composerQueue.v1'
|
||||
|
||||
const load = (): QueueState => {
|
||||
if (typeof window === 'undefined') return {}
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : null
|
||||
|
|
@ -26,10 +29,16 @@ const load = (): QueueState => {
|
|||
}
|
||||
|
||||
const save = (state: QueueState) => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (Object.keys(state).length === 0) window.localStorage.removeItem(STORAGE_KEY)
|
||||
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
if (Object.keys(state).length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
} else {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
} catch {
|
||||
// best-effort: storage may be unavailable, queue still works in-memory
|
||||
}
|
||||
|
|
@ -41,8 +50,11 @@ const writeSession = (sid: string, queue: QueuedPromptEntry[]) => {
|
|||
const current = $queuedPromptsBySession.get()
|
||||
const next = { ...current }
|
||||
|
||||
if (queue.length === 0) delete next[sid]
|
||||
else next[sid] = queue
|
||||
if (queue.length === 0) {
|
||||
delete next[sid]
|
||||
} else {
|
||||
next[sid] = queue
|
||||
}
|
||||
|
||||
$queuedPromptsBySession.set(next)
|
||||
save(next)
|
||||
|
|
@ -72,7 +84,9 @@ export const enqueueQueuedPrompt = (
|
|||
): null | QueuedPromptEntry => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) return null
|
||||
if (!sid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry: QueuedPromptEntry = {
|
||||
id: nextId(),
|
||||
|
|
@ -89,11 +103,15 @@ export const enqueueQueuedPrompt = (
|
|||
export const dequeueQueuedPrompt = (key: string | null | undefined): null | QueuedPromptEntry => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) return null
|
||||
if (!sid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [head, ...rest] = queueFor(sid)
|
||||
|
||||
if (!head) return null
|
||||
if (!head) {
|
||||
return null
|
||||
}
|
||||
|
||||
writeSession(sid, rest)
|
||||
|
||||
|
|
@ -103,12 +121,16 @@ export const dequeueQueuedPrompt = (key: string | null | undefined): null | Queu
|
|||
export const removeQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) return false
|
||||
if (!sid) {
|
||||
return false
|
||||
}
|
||||
|
||||
const queue = queueFor(sid)
|
||||
const next = queue.filter(e => e.id !== id)
|
||||
|
||||
if (next.length === queue.length) return false
|
||||
if (next.length === queue.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
writeSession(sid, next)
|
||||
|
||||
|
|
@ -122,24 +144,32 @@ export const updateQueuedPrompt = (
|
|||
): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) return false
|
||||
if (!sid) {
|
||||
return false
|
||||
}
|
||||
|
||||
const queue = queueFor(sid)
|
||||
let changed = false
|
||||
|
||||
const next = queue.map(entry => {
|
||||
if (entry.id !== id) return entry
|
||||
if (entry.id !== id) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const attachments = update.attachments ? cloneAttachments(update.attachments) : entry.attachments
|
||||
|
||||
if (entry.text === update.text && !update.attachments) return entry
|
||||
if (entry.text === update.text && !update.attachments) {
|
||||
return entry
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...entry, text: update.text, attachments }
|
||||
})
|
||||
|
||||
if (!changed) return false
|
||||
if (!changed) {
|
||||
return false
|
||||
}
|
||||
|
||||
writeSession(sid, next)
|
||||
|
||||
|
|
@ -152,7 +182,9 @@ export const updateQueuedPromptText = (key: string | null | undefined, id: strin
|
|||
export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid || !(sid in $queuedPromptsBySession.get())) return
|
||||
if (!sid || !(sid in $queuedPromptsBySession.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { arraysEqual, insertUniqueId, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage'
|
||||
import {
|
||||
arraysEqual,
|
||||
insertUniqueId,
|
||||
persistBoolean,
|
||||
persistStringArray,
|
||||
storedBoolean,
|
||||
storedStringArray
|
||||
} from '@/lib/storage'
|
||||
|
||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
|
||||
|
||||
|
|
|
|||
|
|
@ -164,7 +164,9 @@ async function fetchProviderDefaultModel(
|
|||
// returned (model.options orders by recency / authenticated state, so
|
||||
// the just-authenticated provider is usually first anyway).
|
||||
const lower = preferredSlugs.map(s => s.toLowerCase())
|
||||
const matched = providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
|
||||
|
||||
const matched =
|
||||
providers.find((p: ModelOptionProvider) => lower.includes(String(p.slug).toLowerCase())) ?? providers[0]
|
||||
|
||||
const models = matched.models ?? []
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,15 @@ export function closeRightRailTab(tabId: RightRailTabId) {
|
|||
|
||||
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
|
||||
|
||||
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
|
||||
export function closeRightRail() {
|
||||
if ($previewTarget.get()) {
|
||||
dismissPreviewTarget()
|
||||
}
|
||||
|
||||
$filePreviewTabs.set([])
|
||||
}
|
||||
|
||||
export function clearSessionPreviewRegistry() {
|
||||
$sessionPreviewRegistry.set({})
|
||||
setPreviewTarget(null)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,13 @@ describe('subagent store', () => {
|
|||
)
|
||||
upsertSubagent(
|
||||
's1',
|
||||
{ status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' },
|
||||
{
|
||||
status: 'running',
|
||||
subagent_id: 'a1',
|
||||
task_index: 0,
|
||||
tool_name: 'search_files',
|
||||
tool_preview: 'pattern=hermes'
|
||||
},
|
||||
false,
|
||||
'subagent.tool'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,15 +56,24 @@ const asStatus = (v: unknown): SubagentStatus =>
|
|||
|
||||
const compact = (text: string, max = PREVIEW_MAX) => {
|
||||
const line = text.replace(/\s+/g, ' ').trim()
|
||||
if (!line) return ''
|
||||
|
||||
if (!line) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
}
|
||||
|
||||
const toolLabel = (name: string) =>
|
||||
name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(p => p[0]!.toUpperCase() + p.slice(1))
|
||||
.join(' ') || name
|
||||
|
||||
const formatTool = (name: string, preview = '') => {
|
||||
const snippet = compact(preview, TOOL_PREVIEW_MAX)
|
||||
|
||||
return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name)
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +99,10 @@ const idOf = (p: SubagentPayload) =>
|
|||
|
||||
const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => {
|
||||
const last = stream.at(-1)
|
||||
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream
|
||||
|
||||
if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) {
|
||||
return stream
|
||||
}
|
||||
|
||||
return [...stream, entry].slice(-MAX_STREAM)
|
||||
}
|
||||
|
|
@ -108,19 +120,29 @@ function streamFromPayload(
|
|||
|
||||
for (const tail of asTail(payload.output_tail)) {
|
||||
const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '')
|
||||
if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
|
||||
|
||||
if (line) {
|
||||
out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line })
|
||||
}
|
||||
}
|
||||
|
||||
if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
|
||||
if (tool) {
|
||||
out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) })
|
||||
}
|
||||
|
||||
if (eventType === 'subagent.progress' && text)
|
||||
if (eventType === 'subagent.progress' && text) {
|
||||
out.push({ at, isError: !!payload.error, kind: 'progress', text })
|
||||
}
|
||||
|
||||
if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text })
|
||||
if (eventType === 'subagent.thinking' && text) {
|
||||
out.push({ at, kind: 'thinking', text })
|
||||
}
|
||||
|
||||
const summary = compact(str(payload.summary) || str(payload.text))
|
||||
if (TERMINAL.has(status) && summary)
|
||||
|
||||
if (TERMINAL.has(status) && summary) {
|
||||
out.push({ at, isError: status === 'failed', kind: 'summary', text: summary })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -158,7 +180,10 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined
|
|||
|
||||
export function clearSessionSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
if (!(sid in map)) return
|
||||
|
||||
if (!(sid in map)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { [sid]: _drop, ...rest } = map
|
||||
$subagentsBySession.set(rest)
|
||||
|
|
@ -167,10 +192,16 @@ export function clearSessionSubagents(sid: string) {
|
|||
export function pruneDelegateFallbackSubagents(sid: string) {
|
||||
const map = $subagentsBySession.get()
|
||||
const list = map[sid]
|
||||
if (!list?.length) return
|
||||
|
||||
if (!list?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = list.filter(item => !item.id.startsWith('delegate-tool:'))
|
||||
if (next.length === list.length) return
|
||||
|
||||
if (next.length === list.length) {
|
||||
return
|
||||
}
|
||||
|
||||
$subagentsBySession.set({ ...map, [sid]: next })
|
||||
}
|
||||
|
|
@ -180,10 +211,16 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
|
|||
const list = map[sid] ?? []
|
||||
const id = idOf(payload)
|
||||
const idx = list.findIndex(item => item.id === id)
|
||||
if (idx < 0 && !createIfMissing) return
|
||||
|
||||
if (idx < 0 && !createIfMissing) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = idx >= 0 ? list[idx] : undefined
|
||||
if (prev && TERMINAL.has(prev.status)) return
|
||||
|
||||
if (prev && TERMINAL.has(prev.status)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = toProgress(payload, prev, eventType)
|
||||
const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next]
|
||||
|
|
@ -193,17 +230,26 @@ export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMi
|
|||
|
||||
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
||||
const nodes = new Map<string, SubagentNode>()
|
||||
for (const item of items) nodes.set(item.id, { ...item, children: [] })
|
||||
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
|
||||
const roots: SubagentNode[] = []
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const parent = node.parentId ? nodes.get(node.parentId) : null
|
||||
if (parent) parent.children.push(node)
|
||||
else roots.push(node)
|
||||
|
||||
if (parent) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
const sort = (a: SubagentNode, b: SubagentNode) =>
|
||||
a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
|
||||
|
||||
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
|
||||
roots.sort(sort).forEach(walk)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,11 +103,11 @@
|
|||
--theme-neutral-chrome: #f3f3f3;
|
||||
--theme-neutral-sidebar: #f3f3f3;
|
||||
--theme-neutral-card: #fcfcfc;
|
||||
--theme-mix-chrome: 44%;
|
||||
--theme-mix-sidebar: 36%;
|
||||
--theme-mix-chrome: 92%;
|
||||
--theme-mix-sidebar: 100%;
|
||||
--theme-mix-card: 22%;
|
||||
--theme-mix-elevated: 28%;
|
||||
--theme-mix-bubble: 30%;
|
||||
--theme-mix-bubble: 0%;
|
||||
--theme-fill-primary-accent-mix: 16%;
|
||||
--theme-fill-secondary-accent-mix: 11%;
|
||||
--theme-fill-tertiary-accent-mix: 8%;
|
||||
|
|
@ -117,6 +117,10 @@
|
|||
--theme-stroke-secondary-accent-mix: 16%;
|
||||
--theme-stroke-tertiary-accent-mix: 10%;
|
||||
--theme-stroke-quaternary-accent-mix: 6%;
|
||||
--theme-row-hover-accent-mix: 4%;
|
||||
--theme-row-active-accent-mix: 8%;
|
||||
--theme-control-hover-accent-mix: 6%;
|
||||
--theme-control-active-accent-mix: 8%;
|
||||
|
||||
--ui-base: var(--theme-foreground);
|
||||
--ui-accent: var(--theme-midground);
|
||||
|
|
@ -164,6 +168,26 @@
|
|||
var(--ui-accent) var(--theme-fill-quinary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||||
);
|
||||
--ui-row-hover-background: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-row-hover-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||||
);
|
||||
--ui-row-active-background: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-row-active-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||||
);
|
||||
--ui-control-hover-background: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-control-hover-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
);
|
||||
--ui-control-active-background: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-control-active-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||||
);
|
||||
--ui-text-primary: color-mix(in srgb, var(--ui-base) 94%, transparent);
|
||||
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
|
||||
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
|
||||
|
|
@ -188,17 +212,18 @@
|
|||
var(--ui-accent) var(--theme-stroke-quaternary-accent-mix),
|
||||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||||
);
|
||||
--glass-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
|
||||
--glass-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
|
||||
--glass-surface-background: var(--ui-bg-editor);
|
||||
--glass-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--glass-chat-surface-background: var(--ui-bg-chrome);
|
||||
--glass-editor-surface-background: var(--ui-bg-chrome);
|
||||
--glass-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--glass-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--glass-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--glass-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
--glass-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||||
--ui-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
|
||||
--ui-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
|
||||
--ui-surface-background: var(--ui-bg-editor);
|
||||
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--ui-chat-surface-background: var(--ui-bg-chrome);
|
||||
--ui-editor-surface-background: var(--ui-bg-chrome);
|
||||
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
|
||||
|
||||
--dt-background: var(--ui-bg-chrome);
|
||||
--dt-foreground: var(--ui-text-primary);
|
||||
|
|
@ -217,12 +242,13 @@
|
|||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-midground: var(--theme-midground);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
--dt-destructive: #cf2d56;
|
||||
--dt-destructive-foreground: #ffffff;
|
||||
--dt-sidebar-bg: var(--ui-bg-sidebar);
|
||||
--dt-sidebar-border: var(--ui-stroke-secondary);
|
||||
--dt-user-bubble: var(--ui-bg-editor);
|
||||
--dt-user-bubble: var(--ui-chat-bubble-background);
|
||||
--dt-user-bubble-border: var(--ui-stroke-tertiary);
|
||||
|
||||
--dt-font-sans: 'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||||
|
|
@ -280,12 +306,12 @@
|
|||
--sidebar-foreground: var(--dt-foreground);
|
||||
--sidebar-primary: var(--dt-primary);
|
||||
--sidebar-primary-foreground: var(--dt-primary-foreground);
|
||||
--sidebar-accent: var(--dt-accent);
|
||||
--sidebar-accent: var(--ui-control-active-background);
|
||||
--sidebar-accent-foreground: var(--dt-accent-foreground);
|
||||
--sidebar-border: var(--dt-sidebar-border);
|
||||
--sidebar-ring: var(--dt-ring);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 7.5%, transparent);
|
||||
--chrome-action-hover: var(--ui-bg-tertiary);
|
||||
--chrome-action-hover: var(--ui-control-hover-background);
|
||||
|
||||
--midground: var(--dt-midground);
|
||||
--background: var(--dt-background);
|
||||
|
|
@ -298,50 +324,28 @@
|
|||
}
|
||||
|
||||
:root.dark {
|
||||
--ui-base: #ffe6cb;
|
||||
--ui-accent: #0053fd;
|
||||
--ui-accent-secondary: #ffe6cb;
|
||||
--ui-warm: #ffe6cb;
|
||||
/* Per-mode mix knobs — overridden inline by `applyTheme()` per skin. */
|
||||
--theme-mix-chrome: 74%;
|
||||
--theme-mix-card: 38%;
|
||||
--theme-mix-elevated: 46%;
|
||||
--theme-mix-bubble: 46%;
|
||||
--theme-neutral-chrome: #0d0d0e;
|
||||
--theme-neutral-sidebar: #0a0a0b;
|
||||
--theme-neutral-card: #161618;
|
||||
|
||||
/* Dark-only accent palette overrides. */
|
||||
--ui-red: #e75e78;
|
||||
--ui-orange: #db704b;
|
||||
--ui-yellow: #c08532;
|
||||
--ui-green: #55a583;
|
||||
--ui-cyan: #6f9ba6;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: #0d1d3a;
|
||||
--ui-bg-sidebar: #0a1833;
|
||||
--ui-bg-editor: #101827;
|
||||
--ui-bg-elevated: #121d32;
|
||||
--ui-bg-input: #131316;
|
||||
--dt-background: var(--ui-bg-chrome);
|
||||
--dt-foreground: var(--ui-text-primary);
|
||||
--dt-card: var(--ui-bg-elevated);
|
||||
--dt-card-foreground: var(--ui-text-primary);
|
||||
--dt-muted: var(--ui-bg-tertiary);
|
||||
--dt-muted-foreground: var(--ui-text-tertiary);
|
||||
--dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
--dt-popover-foreground: var(--ui-text-primary);
|
||||
--dt-primary: var(--ui-accent);
|
||||
--dt-primary-foreground: #0b0b0c;
|
||||
--dt-secondary: var(--ui-bg-secondary);
|
||||
--dt-secondary-foreground: var(--ui-text-secondary);
|
||||
--dt-accent: var(--ui-bg-tertiary);
|
||||
--dt-accent-foreground: var(--ui-text-primary);
|
||||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
--dt-sidebar-bg: var(--ui-bg-sidebar);
|
||||
--dt-sidebar-border: var(--ui-stroke-secondary);
|
||||
--dt-user-bubble: var(--ui-bg-elevated);
|
||||
--dt-user-bubble-border: var(--ui-stroke-tertiary);
|
||||
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
|
||||
--composer-ring-strength: 1.3;
|
||||
--backdrop-invert-mul: 0;
|
||||
--glass-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--glass-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
--glass-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
|
||||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -361,7 +365,7 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--glass-chat-surface-background);
|
||||
background: var(--ui-chat-surface-background);
|
||||
color: var(--dt-foreground);
|
||||
font-family: var(--dt-font-sans);
|
||||
font-size: 0.8125rem;
|
||||
|
|
@ -378,9 +382,22 @@
|
|||
font: inherit;
|
||||
}
|
||||
|
||||
:where(
|
||||
a,
|
||||
.underline,
|
||||
[class~='hover:underline'],
|
||||
[class~='focus:underline'],
|
||||
[class~='focus-visible:underline'],
|
||||
[class~='group-hover:underline'],
|
||||
[class~='peer-hover:underline']
|
||||
) {
|
||||
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
text-underline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
background: var(--dt-midground);
|
||||
color: var(--dt-midground-foreground);
|
||||
background: var(--ui-selection-background);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -742,7 +759,7 @@ canvas {
|
|||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--glass-chat-surface-background) 88%, transparent)
|
||||
color-mix(in srgb, var(--ui-chat-surface-background) 88%, transparent)
|
||||
) !important;
|
||||
}
|
||||
|
||||
|
|
@ -751,12 +768,6 @@ canvas {
|
|||
box-shadow: var(--shadow-composer) !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-surface'] > [aria-hidden='true'] {
|
||||
background: var(--glass-chat-bubble-background) !important;
|
||||
backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(0.75rem) saturate(1.08);
|
||||
}
|
||||
|
||||
[data-slot='composer-fade'] {
|
||||
min-height: 2.375rem;
|
||||
}
|
||||
|
|
@ -771,7 +782,7 @@ canvas {
|
|||
}
|
||||
|
||||
[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] {
|
||||
background: var(--glass-chat-bubble-background) !important;
|
||||
background: var(--ui-chat-bubble-background) !important;
|
||||
}
|
||||
|
||||
/* Tool/thinking blocks now live at message-text alignment (no leading
|
||||
|
|
@ -818,9 +829,9 @@ canvas {
|
|||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
|
||||
border: 0.0625rem solid var(--glass-inline-code-border);
|
||||
background: var(--glass-inline-code-background);
|
||||
color: var(--glass-inline-code-foreground);
|
||||
border: 0.0625rem solid var(--ui-inline-code-border);
|
||||
background: var(--ui-inline-code-background);
|
||||
color: var(--ui-inline-code-foreground);
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) {
|
||||
|
|
@ -843,18 +854,18 @@ canvas {
|
|||
}
|
||||
|
||||
/* Tool / thinking blocks are scaffolding around the model's reply, so we
|
||||
fade them slightly. The reading column (prose) stays at full strength;
|
||||
scaffolding recedes and lifts back to full opacity on hover/focus so it
|
||||
stays legible when the user actually wants to read it. */
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
keep them transparent and fade them slightly. The reading column (prose)
|
||||
stays at full strength; scaffolding lifts back to full opacity on
|
||||
hover/focus so it stays legible when the user actually wants to read it. */
|
||||
[data-slot='tool-block'],
|
||||
[data-slot='aui_thinking-disclosure'] {
|
||||
background: transparent !important;
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot='tool-block'] {
|
||||
background: transparent !important;
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
|
|
@ -909,10 +920,15 @@ canvas {
|
|||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:hover {
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
|
|
|
|||
|
|
@ -148,6 +148,17 @@ function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'l
|
|||
|
||||
// ─── CSS application ────────────────────────────────────────────────────────
|
||||
|
||||
// Per-mode mix knobs. Light/dark fallbacks live in styles.css `:root` /
|
||||
// `:root.dark`; setting them inline keeps active-skin overrides surviving
|
||||
// the boot-time paint.
|
||||
const mixesFor = (isDark: boolean): Record<string, string> => ({
|
||||
'--theme-mix-chrome': isDark ? '74%' : '92%',
|
||||
'--theme-mix-sidebar': '100%',
|
||||
'--theme-mix-card': isDark ? '38%' : '22%',
|
||||
'--theme-mix-elevated': isDark ? '46%' : '28%',
|
||||
'--theme-mix-bubble': isDark ? '46%' : '0%'
|
||||
})
|
||||
|
||||
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
|
|
@ -157,95 +168,54 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
const c = theme.colors
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...nousTheme.typography, ...theme.typography }
|
||||
const rendered = renderedModeFor(c, mode)
|
||||
const isDark = rendered === 'dark'
|
||||
const midground = c.midground ?? c.ring
|
||||
const skinName = theme.name.endsWith(`-${mode}`) ? theme.name.slice(0, -mode.length - 1) : theme.name
|
||||
const neutralChrome = rendered === 'dark' ? '#0d0d0e' : '#f3f3f3'
|
||||
const neutralSidebar = rendered === 'dark' ? '#0a0a0b' : '#f3f3f3'
|
||||
const neutralCard = rendered === 'dark' ? '#161618' : '#fcfcfc'
|
||||
const chromeMix = rendered === 'dark' ? 36 : 44
|
||||
const sidebarMix = rendered === 'dark' ? 42 : 36
|
||||
const cardMix = rendered === 'dark' ? 38 : 22
|
||||
const elevatedMix = rendered === 'dark' ? 46 : 28
|
||||
const bubbleMix = rendered === 'dark' ? 48 : 30
|
||||
|
||||
root.style.setProperty('color-scheme', rendered)
|
||||
root.dataset.hermesTheme = skinName
|
||||
root.dataset.hermesMode = rendered
|
||||
root.classList.toggle('dark', rendered === 'dark')
|
||||
root.classList.toggle('dark', isDark)
|
||||
|
||||
// Brand seeds feed every glass + shadcn token via `color-mix()` in styles.css.
|
||||
const seeds: Record<string, string> = {
|
||||
'--theme-foreground': c.foreground,
|
||||
'--theme-primary': c.primary,
|
||||
'--theme-secondary': c.secondary,
|
||||
'--theme-accent-soft': c.accent,
|
||||
'--theme-midground': midground,
|
||||
'--theme-warm': c.primary,
|
||||
'--theme-background-seed': c.background,
|
||||
'--theme-sidebar-seed': c.sidebarBackground ?? c.background,
|
||||
'--theme-card-seed': c.card,
|
||||
'--theme-elevated-seed': c.popover,
|
||||
'--theme-bubble-seed': c.userBubble ?? c.popover
|
||||
}
|
||||
|
||||
// shadcn/Tailwind tokens that aren't derived from the seed chain.
|
||||
const palette: Record<string, string> = {
|
||||
'--dt-primary-foreground': c.primaryForeground,
|
||||
'--dt-secondary-foreground': c.secondaryForeground,
|
||||
'--dt-accent-foreground': c.accentForeground,
|
||||
'--dt-border': c.border,
|
||||
'--dt-input': c.input,
|
||||
'--dt-ring': c.ring,
|
||||
'--dt-muted': c.muted,
|
||||
'--dt-midground-foreground': c.midgroundForeground ?? readableOn(midground),
|
||||
'--dt-composer-ring': c.composerRing ?? midground,
|
||||
'--dt-destructive': c.destructive,
|
||||
'--dt-destructive-foreground': c.destructiveForeground,
|
||||
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
|
||||
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
|
||||
'--dt-font-sans': typo.fontSans,
|
||||
'--dt-font-mono': typo.fontMono,
|
||||
'--noise-opacity-mul': isDark ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)'
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries({ ...seeds, ...mixesFor(isDark), ...palette })) {
|
||||
root.style.setProperty(k, v)
|
||||
}
|
||||
|
||||
const set = (k: string, v: string) => root.style.setProperty(k, v)
|
||||
set('--theme-foreground', c.foreground)
|
||||
set('--theme-primary', c.primary)
|
||||
set('--theme-secondary', c.secondary)
|
||||
set('--theme-accent-soft', c.accent)
|
||||
set('--theme-midground', midground)
|
||||
set('--theme-warm', c.primary)
|
||||
set('--theme-background-seed', c.background)
|
||||
set('--theme-sidebar-seed', c.sidebarBackground ?? c.background)
|
||||
set('--theme-card-seed', c.card)
|
||||
set('--theme-elevated-seed', c.popover)
|
||||
set('--theme-bubble-seed', c.userBubble ?? c.popover)
|
||||
set('--theme-neutral-chrome', neutralChrome)
|
||||
set('--theme-neutral-sidebar', neutralSidebar)
|
||||
set('--theme-neutral-card', neutralCard)
|
||||
set('--theme-mix-chrome', `${chromeMix}%`)
|
||||
set('--theme-mix-sidebar', `${sidebarMix}%`)
|
||||
set('--theme-mix-card', `${cardMix}%`)
|
||||
set('--theme-mix-elevated', `${elevatedMix}%`)
|
||||
set('--theme-mix-bubble', `${bubbleMix}%`)
|
||||
set('--theme-fill-primary-accent-mix', '16%')
|
||||
set('--theme-fill-secondary-accent-mix', '11%')
|
||||
set('--theme-fill-tertiary-accent-mix', '8%')
|
||||
set('--theme-fill-quaternary-accent-mix', '5%')
|
||||
set('--theme-fill-quinary-accent-mix', '3%')
|
||||
set('--theme-stroke-primary-accent-mix', '24%')
|
||||
set('--theme-stroke-secondary-accent-mix', '16%')
|
||||
set('--theme-stroke-tertiary-accent-mix', '10%')
|
||||
set('--theme-stroke-quaternary-accent-mix', '6%')
|
||||
set('--ui-base', 'var(--theme-foreground)')
|
||||
set('--ui-accent', 'var(--theme-midground)')
|
||||
set('--ui-accent-secondary', 'var(--theme-primary)')
|
||||
set('--ui-warm', 'var(--theme-warm)')
|
||||
set('--ui-bg-chrome', 'color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome))')
|
||||
set('--ui-bg-sidebar', 'color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar))')
|
||||
set('--ui-bg-editor', 'color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card))')
|
||||
set('--ui-bg-elevated', 'color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card))')
|
||||
set('--ui-bg-input', 'var(--ui-bg-editor)')
|
||||
set('--glass-surface-background', 'var(--ui-bg-editor)')
|
||||
set('--glass-sidebar-surface-background', 'var(--ui-bg-sidebar)')
|
||||
set('--glass-chat-surface-background', 'var(--ui-bg-chrome)')
|
||||
set('--glass-editor-surface-background', 'var(--ui-bg-chrome)')
|
||||
set('--glass-chat-bubble-background', 'color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card))')
|
||||
set('--glass-chat-bubble-opaque-background', 'var(--ui-bg-editor)')
|
||||
set('--dt-background', 'var(--ui-bg-chrome)')
|
||||
set('--dt-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-card', 'var(--ui-bg-editor)')
|
||||
set('--dt-card-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-muted', c.muted)
|
||||
set('--dt-muted-foreground', 'var(--ui-text-tertiary)')
|
||||
set('--dt-popover', 'var(--ui-bg-elevated)')
|
||||
set('--dt-popover-foreground', 'var(--ui-text-primary)')
|
||||
set('--dt-primary', 'var(--theme-primary)')
|
||||
set('--dt-primary-foreground', c.primaryForeground)
|
||||
set('--dt-secondary', 'var(--theme-secondary)')
|
||||
set('--dt-secondary-foreground', c.secondaryForeground)
|
||||
set('--dt-accent', 'var(--theme-accent-soft)')
|
||||
set('--dt-accent-foreground', c.accentForeground)
|
||||
set('--dt-border', c.border)
|
||||
set('--dt-input', c.input)
|
||||
set('--dt-ring', c.ring)
|
||||
set('--dt-midground', 'var(--theme-midground)')
|
||||
set('--dt-midground-foreground', c.midgroundForeground ?? readableOn(midground))
|
||||
set('--dt-composer-ring', c.composerRing ?? midground)
|
||||
set('--dt-destructive', c.destructive)
|
||||
set('--dt-destructive-foreground', c.destructiveForeground)
|
||||
set('--dt-sidebar-bg', 'var(--ui-bg-sidebar)')
|
||||
set('--dt-sidebar-border', c.sidebarBorder ?? c.border)
|
||||
set('--dt-user-bubble', 'var(--glass-chat-bubble-background)')
|
||||
set('--dt-user-bubble-border', c.userBubbleBorder ?? c.border)
|
||||
set('--dt-font-sans', typo.fontSans)
|
||||
set('--dt-font-mono', typo.fontMono)
|
||||
set('--noise-opacity-mul', rendered === 'dark' ? 'calc(0.04 / 0.21)' : 'calc(0.34 / 0.21)')
|
||||
window.hermesDesktop?.setTitleBarTheme?.({
|
||||
background: c.background,
|
||||
foreground: c.foreground
|
||||
|
|
|
|||
|
|
@ -242,7 +242,11 @@
|
|||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 3rem 1rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue