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.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { type ComponentPropsWithRef, forwardRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
|
||||
@@ -11,19 +12,20 @@ export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof But
|
||||
}
|
||||
|
||||
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
||||
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
|
||||
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
title={tooltip}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<Tip label={tooltip} side={side}>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -63,10 +64,16 @@ export function NotificationStack() {
|
||||
const [latest, ...olderNotifications] = notifications
|
||||
const overflowCount = olderNotifications.length
|
||||
|
||||
return (
|
||||
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
|
||||
// content z-[130]). Without the portal the stack lives inside the React root
|
||||
// subtree, which any body-level dialog/overlay portal paints over — so a
|
||||
// success toast fired while a dialog is open (or over an OverlayView page)
|
||||
// was invisible. The titlebar-height var only exists inside the app shell
|
||||
// scope, so fall back to its constant (34px) when mounted on <body>.
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label="Notifications"
|
||||
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
role="region"
|
||||
>
|
||||
<NotificationItem notification={latest} />
|
||||
@@ -81,7 +88,8 @@ export function NotificationStack() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -178,7 +179,6 @@ export function CopyButton({
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
@@ -188,34 +188,37 @@ export function CopyButton({
|
||||
|
||||
if (appearance === 'tool-row') {
|
||||
return (
|
||||
<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)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
|
||||
title={feedbackLabel}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -57,12 +58,16 @@ function DialogContent({
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
|
||||
data-slot="dialog-close-button"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -17,15 +17,18 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
// Instant, no transition (the Provider's delayDuration=0 + no animate-*
|
||||
// classes). bg-foreground/text-background auto-inverts per theme: white
|
||||
// on near-black in light mode, black on white in dark.
|
||||
className={cn(
|
||||
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]',
|
||||
className
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
@@ -33,10 +36,34 @@ function TooltipContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Content>, 'content'> {
|
||||
label: React.ReactNode
|
||||
children: React.ReactNode
|
||||
delayDuration?: number
|
||||
}
|
||||
|
||||
// Drop-in replacement for native `title=`: wrap any single element. Instant,
|
||||
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
||||
// anywhere without a provider ancestor. Renders the child untouched when label
|
||||
// is falsy.
|
||||
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
||||
if (!label) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
Reference in New Issue
Block a user