diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index 428511da2..f9d3fc370 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -1,26 +1,43 @@ +import { useRef } from 'react' + +import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone' import { Codicon } from '@/components/ui/codicon' import { cn } from '@/lib/utils' +const COPY: Record<'files' | 'session', { icon: string; label: string }> = { + files: { icon: 'cloud-upload', label: 'Drop files to attach' }, + session: { icon: 'comment-discussion', label: 'Drop to link this chat' } +} + /** - * Full-bleed affordance shown while files are dragged over the chat area. Always - * `pointer-events-none` so the drop lands on the real element underneath and the - * drop-zone handler claims it — the overlay is purely visual. Mirrors the - * composer surface so the two read as one family. + * Full-bleed affordance shown while files or a session are dragged over the chat + * area. Always `pointer-events-none` so the drop lands on the real element + * underneath and the drop-zone handler claims it — the overlay is purely visual. + * Copy adapts to whatever is being dragged; the last kind is held through the + * fade-out so the label doesn't blank. */ -export function ChatDropOverlay({ active }: { active: boolean }) { +export function ChatDropOverlay({ kind }: { kind: DragKind }) { + const lastKind = useRef<'files' | 'session'>('files') + + if (kind) { + lastKind.current = kind + } + + const { icon, label } = COPY[kind ?? lastKind.current] + return (
- - Drop files to attach + + {label}
) diff --git a/apps/desktop/src/app/chat/chat-swap-overlay.tsx b/apps/desktop/src/app/chat/chat-swap-overlay.tsx new file mode 100644 index 000000000..586835092 --- /dev/null +++ b/apps/desktop/src/app/chat/chat-swap-overlay.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' + +import { cn } from '@/lib/utils' + +// Braille spinner frames — reads as a tiny ASCII loader in monospace. +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +// Shown over the conversation while the live gateway swaps to another profile's +// backend (lazily spawned). Keeps the last profile name through the fade-out so +// the label doesn't blank. Purely visual — pointer-events-none. +export function ChatSwapOverlay({ profile }: { profile: string | null }) { + const [frame, setFrame] = useState(0) + const [label, setLabel] = useState(profile) + + useEffect(() => { + if (profile) { + setLabel(profile) + } + }, [profile]) + + useEffect(() => { + if (!profile) { + return + } + + const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80) + + return () => window.clearInterval(id) + }, [profile]) + + return ( +
+
+ {FRAMES[frame]} + Waking up {label}… +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index bf9e72b4b..ae1560e96 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,6 +10,8 @@ * steal focus from the composer effect. */ +import type { InlineRefInput } from './inline-refs' + export type ComposerTarget = 'edit' | 'main' export type ComposerInsertMode = 'block' | 'inline' @@ -23,8 +25,14 @@ interface InsertDetail { text: string } +interface InsertRefsDetail { + refs: InlineRefInput[] + target: ComposerTarget +} + const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' +const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' let activeTarget: ComposerTarget = 'main' @@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => subscribe(INSERT_EVENT, handler) +/** Insert typed ref chips (carrying a display label) into a composer — the + * structured cousin of {@link requestComposerInsert}, used for session links. */ +export const requestComposerInsertRefs = ( + refs: InlineRefInput[], + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + if (refs.length) { + dispatch(INSERT_REFS_EVENT, { refs, target: resolve(target) }) + } +} + +export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => + subscribe(INSERT_REFS_EVENT, handler) + /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 97e7d78a2..1c36b161d 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -44,6 +44,7 @@ import { focusComposerInput, markActiveComposer, onComposerFocusRequest, + onComposerInsertRefsRequest, onComposerInsertRequest } from './focus' import { HelpHint } from './help-hint' @@ -51,7 +52,12 @@ import { useAtCompletions } from './hooks/use-at-completions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' -import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs' +import { + dragHasAttachments, + droppedFileInlineRef, + type InlineRefInput, + insertInlineRefsIntoEditor +} from './inline-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -431,7 +437,7 @@ export function ChatBar({ requestMainFocus() } - const insertInlineRefs = (refs: string[]) => { + const insertInlineRefs = (refs: InlineRefInput[]) => { const editor = editorRef.current if (!editor) { @@ -451,6 +457,19 @@ export function ChatBar({ return true } + // Latest-closure ref so the (once-only) subscription always calls the current + // insertInlineRefs without re-subscribing every render. + const insertInlineRefsRef = useRef(insertInlineRefs) + insertInlineRefsRef.current = insertInlineRefs + + useEffect(() => { + return onComposerInsertRefsRequest(({ refs, target }) => { + if (target === 'main') { + insertInlineRefsRef.current(refs) + } + }) + }, []) + const selectSkinSlashCommand = (command: string) => { draftRef.current = command aui.composer().setText(command) diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index bb59a9c68..c8fa48d6e 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions' import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor' +/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ +export type InlineRefInput = string | { kind: string; label?: string; value: string } + +/** MIME for an in-app session drag (sidebar row → composer). */ +export const HERMES_SESSION_MIME = 'application/x-hermes-session' + +export interface SessionDragPayload { + id: string + profile: string + title: string +} + +export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) { + transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload)) + transfer.effectAllowed = 'copy' +} + +export function dragHasSession(transfer: DataTransfer | null) { + return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME) +} + +export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload { + const raw = transfer?.getData(HERMES_SESSION_MIME) + + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) as Partial + + return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null + } catch { + return null + } +} + +/** Build a `@session:/` chip. Value carries the metadata the agent + * needs to resolve the link (session_search); label shows the friendly title. */ +export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput { + return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` } +} + export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) { if (!transfer) { return false @@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null return `@${kind}:${formatRefValue(rel)}` } -export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) { +export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { if (!refs.length) { return null } const refsHtml = refs .map(ref => { + if (typeof ref !== 'string') { + return refChipHtml(ref.kind, ref.value, ref.label) + } + const match = ref.match(/^@([^:]+):(.+)$/) return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 3a45028e7..38ab85d0f 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -15,7 +15,7 @@ import { export const RICH_INPUT_SLOT = 'composer-rich-input' -export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g +export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } @@ -52,14 +52,14 @@ export function quoteRefValue(value: string) { return formatRefValue(value) } -export function refChipHtml(kind: string, rawValue: string) { +export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) { const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - return `${directiveIconSvg(kind)}${escapeHtml(refLabel(id))}` + return `${directiveIconSvg(kind)}${escapeHtml(displayLabel || refLabel(id))}` } -export function refChipElement(kind: string, rawValue: string) { +export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` const chip = document.createElement('span') @@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) { chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = refLabel(id) + label.textContent = displayLabel || refLabel(id) chip.append(directiveIconElement(kind), label) return chip diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 3254cbf04..10b3cfe40 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -1,50 +1,71 @@ import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react' -import { dragHasAttachments } from '@/app/chat/composer/inline-refs' +import { + dragHasAttachments, + dragHasSession, + readSessionDrag, + type SessionDragPayload +} from '@/app/chat/composer/inline-refs' import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions' -const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) +export type DragKind = 'files' | 'session' | null + +const dragKindOf = (event: ReactDragEvent): DragKind => { + if (dragHasSession(event.dataTransfer)) { + return 'session' + } + + if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return 'files' + } + + return null +} interface FileDropZoneOptions { /** When false the zone ignores drags entirely. */ enabled?: boolean onDropFiles: (files: DroppedFile[]) => void + onDropSession?: (session: SessionDragPayload) => void } /** - * "Drop files anywhere in this region" affordance. An enter/leave depth counter - * keeps nested children from flickering the active state; `onDropCapture` clears - * it even when a nested target (the composer) handles the drop and stops - * propagation before our bubble-phase `onDrop` would fire. + * "Drop anywhere in this region" affordance for files *and* in-app session + * links. An enter/leave depth counter keeps nested children from flickering the + * active state; `onDropCapture` clears it even when a nested target (the + * composer) handles the drop and stops propagation before our bubble-phase + * `onDrop` would fire. * - * Spread `dropHandlers` onto the container; render an overlay off `dragActive`. + * Spread `dropHandlers` onto the container; render an overlay off `dragKind`. */ -export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) { - const [dragActive, setDragActive] = useState(false) +export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) { + const [dragKind, setDragKind] = useState(null) const depth = useRef(0) const reset = useCallback(() => { depth.current = 0 - setDragActive(false) + setDragKind(null) }, []) const onDragEnter = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + const kind = enabled ? dragKindOf(event) : null + + if (!kind) { return } event.preventDefault() depth.current += 1 - setDragActive(true) + setDragKind(kind) }, [enabled] ) const onDragOver = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + if (!enabled || !dragKindOf(event)) { return } @@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt const onDrop = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + const kind = enabled ? dragKindOf(event) : null + + if (!kind) { return } event.preventDefault() reset() + if (kind === 'session') { + const session = readSessionDrag(event.dataTransfer) + + if (session) { + onDropSession?.(session) + } + + return + } + const files = extractDroppedFiles(event.dataTransfer) if (files.length) { onDropFiles(files) } }, - [enabled, onDropFiles, reset] + [enabled, onDropFiles, onDropSession, reset] ) - return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } } + return { + dragKind, + dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } + } } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 0c14ab5ca..ec8f9df3f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -22,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, $awaitingResponse, @@ -45,9 +46,10 @@ import { routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' +import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' -import { requestComposerInsert } from './composer/focus' -import { droppedFileInlineRef } from './composer/inline-refs' +import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus' +import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs' import type { ChatBarState } from './composer/types' import type { DroppedFile } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' @@ -178,6 +180,7 @@ export function ChatView({ const currentProvider = useStore($currentProvider) const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) + const gatewaySwapTarget = useStore($gatewaySwapTarget) const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) @@ -306,7 +309,13 @@ export function ChatView({ [currentCwd] ) - const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles }) + // Dropping a sidebar session inserts an @session link the agent can resolve + // via session_search (carries the source profile, so cross-profile works). + const onDropSession = useCallback((session: SessionDragPayload) => { + requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' }) + }, []) + + const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession }) return (
)} - + +
) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 8542619f4..4c79e85b0 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' +import { writeSessionDrag } from '@/app/chat/composer/inline-refs' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' @@ -87,6 +88,22 @@ export function SidebarSessionRow({ className )} data-working={isWorking ? 'true' : undefined} + draggable + onDragStart={event => { + // Reorder drags belong to dnd-kit (the grab handle) — cancel the + // native drag so the two DnD systems don't fight. + if ((event.target as HTMLElement).closest('[data-reorder-handle]')) { + event.preventDefault() + + return + } + + writeSessionDrag(event.dataTransfer, { + id: session.id, + profile: session.profile || 'default', + title + }) + }} ref={ref} style={style} {...rest} @@ -132,6 +149,7 @@ export function SidebarSessionRow({ // out instead of being clipped by this handle's overflow-hidden. needsInput && 'overflow-visible' )} + data-reorder-handle onClick={event => event.stopPropagation()} > | null = null let reconnectAttempt = 0 + // Surface "sign in again" once per disconnect episode, not on every backoff + // tick — a stale OAuth ticket fails every attempt and would otherwise stack + // identical error toasts (and their haptics). Reset on the next clean open. + let reauthNotified = false // Wrap the live getter in a call so TS control-flow analysis doesn't narrow // `connectionState` to a constant across the early-return guards (the state @@ -128,7 +132,8 @@ export function useGatewayBoot({ // again" message once instead of silently looping the backoff against a // ticket that can never succeed. Transport failures fall through to the // backoff in the finally block below. - if (!cancelled && isGatewayReauthRequired(err)) { + if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) { + reauthNotified = true notifyError(err, 'Gateway sign-in required') } } finally { @@ -188,6 +193,7 @@ export function useGatewayBoot({ if (st === 'open') { reconnectAttempt = 0 + reauthNotified = false clearReconnectTimer() } else if (bootCompleted && (st === 'closed' || st === 'error')) { // The socket dropped after a healthy boot (typically sleep/wake). Try diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 9189356dc..c1cde84d4 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/chat/zoomable-image' import { extractEmbeddedImages } from '@/lib/embedded-images' -const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const +const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const type HermesRefType = (typeof HERMES_REF_TYPES)[number] /** Single source of truth for chip icon glyphs (Tabler outline @ 24×24). @@ -38,7 +38,12 @@ const ICON_PATHS: Record = { ], tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'], line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'], - terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'] + terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'], + session: [ + 'M8 9h8', + 'M8 13h6', + 'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z' + ] } const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'] @@ -98,7 +103,7 @@ const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( * raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain * muted-foreground text so chips read as quiet tags on any bubble color. */ export const DIRECTIVE_CHIP_CLASS = - 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' + 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' /** * Parses our composer's `@type:value` references into directive segments @@ -113,7 +118,7 @@ export const DIRECTIVE_CHIP_CLASS = const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g const HERMES_DIRECTIVE_RE = new RegExp( - '@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')', + '@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')', 'g' ) @@ -263,6 +268,14 @@ function shortLabel(type: HermesRefType, id: string): string { } } + // `@session:/` — show a short id; the composer chip carries the + // friendly title, but once sent the wire form only has the id. + if (type === 'session') { + const sid = id.split('/').filter(Boolean).pop() || id + + return sid.length > 10 ? `${sid.slice(0, 8)}…` : sid + } + const tail = id.split(/[\\/]/).filter(Boolean).pop() return tail || id diff --git a/apps/desktop/src/lib/haptics.ts b/apps/desktop/src/lib/haptics.ts index 7b1a9c3d9..83daf7bef 100644 --- a/apps/desktop/src/lib/haptics.ts +++ b/apps/desktop/src/lib/haptics.ts @@ -87,6 +87,15 @@ export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => P let registeredTrigger: HapticTrigger | null = null let lastSelectionAt = 0 +// Global rolling rate-limit. A runaway upstream loop (auth-expiry error-toast +// storms, reconnect flaps) can request dozens of haptics a second, which the +// trackpad actuator renders as a frantic "clickity" buzz. Cap firings to +// RATE_LIMIT per RATE_WINDOW so no source can machine-gun the actuator; +// intentional UI haptics are human-paced and never approach the ceiling. +const RATE_WINDOW = 1000 +const RATE_LIMIT = 5 +let recentFires: number[] = [] + export function registerHapticTrigger(trigger: HapticTrigger | null) { registeredTrigger = trigger } @@ -106,6 +115,14 @@ export function triggerHaptic(intent: HapticIntent = 'selection') { lastSelectionAt = now } + recentFires = recentFires.filter(t => now - t < RATE_WINDOW) + + if (recentFires.length >= RATE_LIMIT) { + return + } + + recentFires.push(now) + const config = HAPTIC_INTENTS[intent] void registeredTrigger(config.pattern, config.options)?.catch(() => undefined) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 2add7efe9..5a59de89b 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -141,6 +141,11 @@ $activeGatewayProfile.subscribe(value => { _lastRoutedProfile = key }) +// Target profile while a gateway swap is mid-flight (spawning/reconnecting that +// profile's backend), else null. Drives the chat's "waking up " loader +// so a lazy spawn doesn't read as a hang. Single-profile users never swap. +export const $gatewaySwapTarget = atom(null) + let gatewaySwitch: Promise | null = null // Reconnect the single live gateway to `profile`'s backend if it isn't already @@ -176,6 +181,7 @@ export async function ensureGatewayProfile(profile: string | null | undefined): } } + $gatewaySwapTarget.set(target) gatewaySwitch = (async () => { const desktop = window.hermesDesktop const gateway = $gateway.get() @@ -205,6 +211,7 @@ export async function ensureGatewayProfile(profile: string | null | undefined): await gatewaySwitch } finally { gatewaySwitch = null + $gatewaySwapTarget.set(null) } } diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 3f517aa1a..f564504e1 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -399,3 +399,124 @@ class TestShapePrecedence: _seed_modpack_sessions(db) result = json.loads(session_search(query=None, db=db)) # type: ignore assert result["mode"] == "browse" + + def test_session_id_without_anchor_reads(self, db): + _seed_modpack_sessions(db) + # session_id alone (no anchor, no query) → read shape, not browse. + result = json.loads(session_search(session_id="s_oldest", db=db)) + assert result["mode"] == "read" + + +# ========================================================================= +# Read shape — dump a whole session by id (serves @session links) +# ========================================================================= + +class TestReadShape: + def test_read_returns_full_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(session_id="s_oldest", db=db)) + assert result["success"] is True + assert result["mode"] == "read" + assert result["session_id"] == "s_oldest" + assert result["message_count"] == 5 + assert result["truncated"] is False + assert len(result["messages"]) == 5 + assert result["session_meta"]["title"] == "Building the Modpack" + + def test_read_unknown_session_errors(self, db): + result = json.loads(session_search(session_id="ghost", db=db)) + assert result["success"] is False + + def test_read_truncates_large_session(self, db): + db.create_session("s_big", source="cli") + for i in range(50): + db.append_message("s_big", role="user" if i % 2 == 0 else "assistant", content=f"m{i}") + db._conn.commit() + result = json.loads(session_search(session_id="s_big", db=db)) + assert result["mode"] == "read" + assert result["message_count"] == 50 + assert result["truncated"] is True + assert len(result["messages"]) == 30 # head 20 + tail 10 + + +# ========================================================================= +# Cross-profile read — `profile` swaps in another profile's DB (read-only) +# ========================================================================= + +class TestCrossProfileRead: + def _patch_profiles(self, monkeypatch, home, exists=True): + from hermes_cli import profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n) + monkeypatch.setattr(profiles_mod, "validate_profile_name", lambda n: None) + monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: exists) + monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: home) + + def test_profile_param_reads_other_db(self, db, tmp_path, monkeypatch): + other_home = tmp_path / "other_home" + other_home.mkdir() + other = SessionDB(other_home / "state.db") + other.create_session("s_other", source="cli") + other._conn.execute( + "UPDATE sessions SET title = ? WHERE id = ?", ("Other Profile Chat", "s_other") + ) + other.append_message("s_other", role="user", content="hello from the other profile") + other._conn.commit() + + self._patch_profiles(monkeypatch, other_home) + + # s_other lives only in the other profile; the current `db` lacks it. + result = json.loads(session_search(session_id="s_other", profile="other", db=db)) + assert result["success"] is True + assert result["mode"] == "read" + assert result["session_meta"]["title"] == "Other Profile Chat" + + def test_bare_id_locates_across_profiles(self, db, tmp_path, monkeypatch): + # The real-world failure: model dropped the owning profile and passed a + # bare id. The tool must scan profiles and find it anyway. + other_home = tmp_path / "asdf_home" + other_home.mkdir() + other = SessionDB(other_home / "state.db") + other.create_session("s_far", source="cli") + other.append_message("s_far", role="user", content="hi") + other._conn.commit() + + from collections import namedtuple + from hermes_cli import profiles as profiles_mod + Info = namedtuple("Info", "name path") + monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: tmp_path / "default_home") + monkeypatch.setattr(profiles_mod, "list_profiles", lambda: [Info("asdf", other_home)]) + + # `db` (current profile) lacks s_far; no profile passed → scan finds it. + result = json.loads(session_search(session_id="s_far", db=db)) + assert result["success"] is True + assert result["mode"] == "read" + assert result["profile"] == "asdf" + + def test_unknown_profile_errors(self, db, monkeypatch, tmp_path): + self._patch_profiles(monkeypatch, tmp_path, exists=False) + result = json.loads(session_search(session_id="x", profile="ghost", db=db)) + assert result["success"] is False + assert "ghost" in result.get("error", "") + + def test_combined_value_autosplits(self, db, tmp_path, monkeypatch): + # Agent passed the raw "@session:/" value as session_id with + # no separate profile — the tool should recover both. + other_home = tmp_path / "other_home" + other_home.mkdir() + other = SessionDB(other_home / "state.db") + other.create_session("s_other", source="cli") + other.append_message("s_other", role="user", content="hi") + other._conn.commit() + + self._patch_profiles(monkeypatch, other_home) + + # Every permutation the model might send must resolve to (asdf, s_other). + for kwargs in ( + {"session_id": "asdf/s_other"}, # full value, no profile + {"session_id": "asdf/s_other", "profile": "asdf"}, # full value AND profile + {"session_id": "s_other", "profile": "asdf"}, # bare id + profile + ): + result = json.loads(session_search(db=db, **kwargs)) + assert result["success"] is True, kwargs + assert result["mode"] == "read" + assert result["session_id"] == "s_other" diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 65b9d32f1..7bbb26a2e 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -107,6 +107,122 @@ def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[s return {k: v for k, v in entry.items() if v is not None or k in ("content",)} +def _resolve_profile_db(profile: str): + """Open another profile's ``state.db`` read-only, or None for the current one. + + The desktop's ``@session:/`` links always carry the source + profile, so a linked session from profile B can be read while the agent + runs in profile A. ``read_only=True`` (mode=ro) takes no write lock — safe + to point at a live profile's DB, including our own. Returns None when no + profile is given (use the caller's default db). + """ + if profile is None or not str(profile).strip(): + return None + + from hermes_cli import profiles as profiles_mod + from hermes_state import SessionDB + + canon = profiles_mod.normalize_profile_name(profile) + profiles_mod.validate_profile_name(canon) + if not profiles_mod.profile_exists(canon): + raise ValueError(f"profile '{canon}' does not exist") + + return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True) + + +def _locate_session_db(session_id: str): + """Scan every profile's ``state.db`` (read-only) for a session id. + + Returns ``(db, profile_name)`` for the first profile that owns the id, or + ``(None, None)``. Session ids are globally unique (timestamp + random hex), + so the first hit is authoritative. This is the safety net for linked-session + reads where the model dropped the owning profile from the link and passed a + bare id — we find it wherever it actually lives instead of failing. + """ + from pathlib import Path + + try: + from hermes_cli import profiles as profiles_mod + from hermes_state import SessionDB + except Exception: + return None, None + + targets = [("default", profiles_mod.get_profile_dir("default"))] + try: + targets += [(info.name, info.path) for info in profiles_mod.list_profiles()] + except Exception: + logging.debug("list_profiles failed during session locate", exc_info=True) + + seen: set = set() + for name, home in targets: + db_path = Path(home) / "state.db" + key = str(db_path) + if key in seen or not db_path.exists(): + continue + seen.add(key) + try: + pdb = SessionDB(db_path=db_path, read_only=True) + except Exception: + continue + try: + if pdb.get_session(session_id): + return pdb, name + except Exception: + logging.debug("get_session probe failed for %s in %s", session_id, name, exc_info=True) + pdb.close() + + return None, None + + +def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: + """Read shape: dump a whole session by id (head + tail when large). + + Serves the linked-session case — the user dropped an @session reference and + the agent wants the transcript. Bounded payload: small sessions return in + full, large ones return the first ``head`` and last ``tail`` messages with a + pointer to scroll the middle. + """ + try: + meta = db.get_session(session_id) or {} + except Exception as e: + logging.debug("get_session failed for %s: %s", session_id, e, exc_info=True) + meta = {} + if not meta: + return tool_error(f"session_id not found: {session_id}", success=False) + + try: + rows = db.get_messages(session_id) + except Exception as e: + logging.error("get_messages failed for %s: %s", session_id, e, exc_info=True) + return tool_error(f"failed to load session: {e}", success=False) + + shaped = [_shape_message(m) for m in rows] + total = len(shaped) + truncated = total > head + tail + window = shaped[:head] + shaped[-tail:] if truncated else shaped + + response = { + "success": True, + "mode": "read", + "session_id": session_id, + "session_meta": { + "when": _format_timestamp(meta.get("started_at")), + "source": meta.get("source"), + "model": meta.get("model"), + "title": meta.get("title"), + }, + "message_count": total, + "truncated": truncated, + "messages": window, + } + if truncated: + response["message"] = ( + f"Session has {total} messages; showing first {head} + last {tail}. " + "Pass around_message_id (any id above) to scroll the middle." + ) + return json.dumps(response, ensure_ascii=False) + + def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: """Return metadata for the most recent sessions (no LLM calls, no FTS5).""" try: @@ -387,15 +503,19 @@ def session_search( window: int = 5, # Discovery shape sort: str = None, + # Cross-profile (any shape) + profile: str = None, ) -> str: """Single-shape tool. Mode inferred from which args are set. Discovery: pass ``query``. Scroll: pass ``session_id`` + ``around_message_id``. + Read: pass ``session_id`` (no anchor) — dumps the whole session. Browse: pass nothing. - Scroll wins over discovery when both are set — the agent has explicitly - asked for a slice of a known session. + Pass ``profile`` to read another profile's sessions (e.g. resolving an + ``@session:/`` link). Scroll wins over read/discovery when an + anchor is set — the agent has asked for a specific slice. """ if db is None: try: @@ -406,6 +526,30 @@ def session_search( from hermes_state import format_session_db_unavailable return tool_error(format_session_db_unavailable(), success=False) + # Normalise a raw `@session:/` link value passed as session_id. + # Session ids never contain "/", so a slash unambiguously means profile/id — + # always strip the prefix off the id, and adopt the embedded profile only + # when one wasn't passed explicitly. Handles every permutation the model + # might send (full value as id, with or without a separate profile=). + if isinstance(session_id, str) and "/" in session_id: + emb_profile, _, emb_id = session_id.partition("/") + if emb_id: + session_id = emb_id + if emb_profile and (profile is None or not str(profile).strip()): + profile = emb_profile + + # Cross-profile read: swap in the named profile's DB (read-only) for every + # shape below. The current-session-lineage guards no longer apply across + # profiles, but they key off ids that won't collide, so they stay inert. + if profile is not None and str(profile).strip(): + try: + profile_db = _resolve_profile_db(profile) + except Exception as e: + return tool_error(f"profile '{profile}': {e}", success=False) + if profile_db is not None: + db = profile_db + current_session_id = None + # Scroll shape takes precedence — explicit anchor beats any query. if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None: return _scroll( @@ -416,6 +560,27 @@ def session_search( current_session_id=current_session_id, ) + # Read shape: a session_id with no anchor → dump the whole session. + if isinstance(session_id, str) and session_id.strip(): + sid = session_id.strip() + result = _read_session(db, sid) + if json.loads(result).get("success"): + return result + + # Miss in the target profile — the model may have dropped the owning + # profile from the link. Scan every profile and read it from wherever + # it lives, tagging the profile it was found in. + located, owner = _locate_session_db(sid) + if located is not None: + try: + found = json.loads(_read_session(located, sid)) + finally: + located.close() + if found.get("success"): + found["profile"] = owner + return json.dumps(found, ensure_ascii=False) + return result + # Limit clamp [1, 10] if not isinstance(limit, int): try: @@ -465,7 +630,7 @@ SESSION_SEARCH_SCHEMA = { "Search past sessions stored in the local session DB, or scroll inside one. " "FTS5-backed retrieval over the SQLite message store. No LLM calls — every " "shape returns actual messages from the DB.\n\n" - "THREE CALLING SHAPES\n\n" + "FOUR CALLING SHAPES\n\n" " 1) DISCOVERY — pass `query`:\n" " session_search(query=\"auth refactor\", limit=3)\n" " Runs FTS5, dedupes hits by session lineage, returns the top N sessions. " @@ -491,7 +656,13 @@ SESSION_SEARCH_SCHEMA = { " - The boundary message appears in both windows — orientation marker.\n" " - When messages_before or messages_after is < window, you're at the " "start or end of the session.\n\n" - " 3) BROWSE — no args:\n" + " 3) READ — pass `session_id` only (no around_message_id):\n" + " session_search(session_id=\"...\", profile=\"work\")\n" + " Dumps the whole session by id (first 20 + last 10 messages when " + "large). This is how you resolve an `@session:/` link the " + "user dropped into the chat: split the value on `/` into profile + id " + "and call session_search(session_id=id, profile=profile).\n\n" + " 4) BROWSE — no args:\n" " session_search()\n" " Returns recent sessions chronologically: titles, previews, timestamps. " "Use when the user asks \"what was I working on\" without naming a topic.\n\n" @@ -573,6 +744,15 @@ SESSION_SEARCH_SCHEMA = { "behaviour) or 'tool' to search tool output only." ), }, + "profile": { + "type": "string", + "description": ( + "Optional. Read sessions from another Hermes profile's database " + "(read-only). Use when resolving an `@session:/` link: " + "pass the profile segment here with session_id as the id segment. " + "Omit to use the current profile." + ), + }, }, "required": [], }, @@ -594,6 +774,7 @@ registry.register( around_message_id=args.get("around_message_id"), window=args.get("window", 5), sort=args.get("sort"), + profile=args.get("profile"), db=kw.get("db"), current_session_id=kw.get("current_session_id"), ),