hermes-agent/apps/desktop/src/components/ui/copy-button.tsx
Brooklyn Nicholson b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
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.
2026-06-04 16:35:34 -05:00

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
}