feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id> chip the agent resolves via session_search. New READ shape dumps a whole session by id (head+tail when large); a `profile` param reads another profile's DB read-only, and a cross-profile locate scan resolves bare ids when the model drops the owning profile from the link. Also: ASCII "waking up <profile>" overlay during lazy gateway swaps, global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and reauth toasts surfaced once per disconnect instead of every backoff tick.
This commit is contained in:
@ -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 (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
active ? 'opacity-100' : 'opacity-0'
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
|
||||
Drop files to attach
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
@ -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<null | string>(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 (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
profile ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
||||
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
||||
Waking up {label}…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<InsertDetail>(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<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `@session:<profile>/<id>` 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)
|
||||
|
||||
@ -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<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@ -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 `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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<DragKind>(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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
@ -370,7 +379,8 @@ export function ChatView({
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<ChatDropOverlay active={dragActive} />
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
|
||||
@ -77,6 +77,10 @@ export function useGatewayBoot({
|
||||
let reconnecting = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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
|
||||
|
||||
@ -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<HermesRefType, string[]> = {
|
||||
],
|
||||
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:<profile>/<id>` — 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 <profile>" loader
|
||||
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
|
||||
export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
let gatewaySwitch: Promise<void> | 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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:<profile>/<id>" 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"
|
||||
|
||||
@ -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:<profile>/<id>`` 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:<profile>/<id>`` 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:<profile>/<id>` 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:<profile>/<id>` 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:<profile>/<id>` 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"),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user