Add first-class profile support to the desktop app without app reloads. - Swap the single live gateway onto a session's profile lazily (spawned on demand by the Electron backend pool), so one backend serves the active profile and others stay cold — no OOM with many profiles. - Aggregate sessions across profiles by reading each profile's state.db read-only; unified "All profiles" view groups sessions per profile with per-profile pagination, while the default view stays scoped to one profile. - Add an Arc-style profile rail at the sidebar foot: a default<->all toggle pinned left, colored named-profile squares scrolling between, Manage pinned right. Profile identity is a deterministic per-name color. - Route profile-scoped REST (config/env/skills/tools/model) to the active gateway profile and invalidate React Query caches on swap. Single-profile users never trigger a swap, so their path is unchanged. Backend: - web_server: profile-aware active/list endpoints + per-profile session totals; hermes_state: session_count(exclude_children); main.py: honor --profile over HERMES_HOME env for pooled backends. UI primitives: - Add a position-aware Tip tooltip (instant, themed) as a drop-in for native title=, and strip redundant tooltips from self-descriptive chrome.
224 lines
5.6 KiB
TypeScript
224 lines
5.6 KiB
TypeScript
import * as React from 'react'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
|
import { Tip } from '@/components/ui/tooltip'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { Check, Copy, X } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type CopyPayload = string | (() => Promise<string> | string)
|
|
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
|
type CopyStatus = 'copied' | 'error' | 'idle'
|
|
const COPIED_RESET_MS = 1_500
|
|
|
|
export async function writeClipboardText(text: string) {
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
if (window.hermesDesktop?.writeClipboard) {
|
|
await window.hermesDesktop.writeClipboard(text)
|
|
|
|
return
|
|
}
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text)
|
|
|
|
return
|
|
}
|
|
|
|
throw new Error('Clipboard API is unavailable')
|
|
}
|
|
|
|
export interface CopyButtonProps {
|
|
appearance?: CopyButtonAppearance
|
|
buttonSize?: React.ComponentProps<typeof Button>['size']
|
|
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
|
children?: React.ReactNode
|
|
className?: string
|
|
disabled?: boolean
|
|
errorMessage?: string
|
|
haptic?: boolean
|
|
iconClassName?: string
|
|
label?: string
|
|
onCopied?: () => void
|
|
onCopyError?: (error: unknown) => void
|
|
preventDefault?: boolean
|
|
showLabel?: boolean
|
|
stopPropagation?: boolean
|
|
text: CopyPayload
|
|
title?: string
|
|
}
|
|
|
|
export function CopyButton({
|
|
appearance = 'button',
|
|
buttonSize,
|
|
buttonVariant = 'ghost',
|
|
children,
|
|
className,
|
|
disabled = false,
|
|
errorMessage = 'Copy failed',
|
|
haptic = true,
|
|
iconClassName,
|
|
label = 'Copy',
|
|
onCopied,
|
|
onCopyError,
|
|
preventDefault = false,
|
|
showLabel,
|
|
stopPropagation = false,
|
|
text,
|
|
title
|
|
}: CopyButtonProps) {
|
|
const [status, setStatus] = React.useState<CopyStatus>('idle')
|
|
const resetRef = React.useRef<number | null>(null)
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (resetRef.current !== null) {
|
|
window.clearTimeout(resetRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const copy = React.useCallback(
|
|
async (event?: Event | React.MouseEvent<HTMLElement>) => {
|
|
if (preventDefault) {
|
|
event?.preventDefault()
|
|
}
|
|
|
|
if (stopPropagation) {
|
|
event?.stopPropagation()
|
|
}
|
|
|
|
try {
|
|
const value = typeof text === 'function' ? await text() : text
|
|
|
|
if (!value) {
|
|
return
|
|
}
|
|
|
|
await writeClipboardText(value)
|
|
|
|
if (haptic) {
|
|
triggerHaptic('selection')
|
|
}
|
|
|
|
if (resetRef.current !== null) {
|
|
window.clearTimeout(resetRef.current)
|
|
}
|
|
|
|
setStatus('copied')
|
|
resetRef.current = window.setTimeout(() => {
|
|
setStatus('idle')
|
|
resetRef.current = null
|
|
}, COPIED_RESET_MS)
|
|
onCopied?.()
|
|
} catch (error) {
|
|
onCopyError?.(error)
|
|
|
|
if (resetRef.current !== null) {
|
|
window.clearTimeout(resetRef.current)
|
|
}
|
|
|
|
setStatus('error')
|
|
resetRef.current = window.setTimeout(() => {
|
|
setStatus('idle')
|
|
resetRef.current = null
|
|
}, COPIED_RESET_MS)
|
|
}
|
|
},
|
|
[haptic, onCopied, onCopyError, preventDefault, stopPropagation, text]
|
|
)
|
|
|
|
const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy
|
|
const icon = <Icon className={cn('size-3.5', iconClassName)} />
|
|
|
|
const visibleChildren =
|
|
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
|
|
? status === 'copied'
|
|
? 'Copied'
|
|
: status === 'error'
|
|
? 'Failed'
|
|
: (children ?? label)
|
|
: null
|
|
|
|
const content = (
|
|
<>
|
|
{icon}
|
|
{visibleChildren}
|
|
</>
|
|
)
|
|
|
|
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
|
|
const ariaLabel = status === 'idle' ? label : feedbackLabel
|
|
|
|
if (appearance === 'menu-item') {
|
|
return (
|
|
<DropdownMenuItem
|
|
className={className}
|
|
disabled={disabled}
|
|
onSelect={event => {
|
|
event.preventDefault()
|
|
void copy(event)
|
|
}}
|
|
>
|
|
{content}
|
|
</DropdownMenuItem>
|
|
)
|
|
}
|
|
|
|
if (appearance === 'inline') {
|
|
return (
|
|
<button
|
|
aria-label={ariaLabel}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40',
|
|
className
|
|
)}
|
|
disabled={disabled}
|
|
onClick={event => void copy(event)}
|
|
type="button"
|
|
>
|
|
{content}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (appearance === 'tool-row') {
|
|
return (
|
|
<Tip label={feedbackLabel}>
|
|
<button
|
|
aria-label={ariaLabel}
|
|
className={cn(
|
|
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
|
|
className
|
|
)}
|
|
disabled={disabled}
|
|
onClick={event => void copy(event)}
|
|
type="button"
|
|
>
|
|
{icon}
|
|
</button>
|
|
</Tip>
|
|
)
|
|
}
|
|
|
|
const button = (
|
|
<Button
|
|
aria-label={ariaLabel}
|
|
className={className}
|
|
disabled={disabled}
|
|
onClick={event => void copy(event)}
|
|
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
|
|
type="button"
|
|
variant={buttonVariant}
|
|
>
|
|
{content}
|
|
</Button>
|
|
)
|
|
|
|
// Only icon-only buttons need a tooltip; the text variant already shows its label.
|
|
return appearance === 'icon' ? <Tip label={feedbackLabel}>{button}</Tip> : button
|
|
}
|