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"),
),