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 { Codicon } from '@/components/ui/codicon'
|
||||||
import { cn } from '@/lib/utils'
|
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
|
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||||
* `pointer-events-none` so the drop lands on the real element underneath and the
|
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||||
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
|
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||||
* composer surface so the two read as one family.
|
* 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
'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"
|
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="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">
|
<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" />
|
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||||
Drop files to attach
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</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.
|
* steal focus from the composer effect.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { InlineRefInput } from './inline-refs'
|
||||||
|
|
||||||
export type ComposerTarget = 'edit' | 'main'
|
export type ComposerTarget = 'edit' | 'main'
|
||||||
export type ComposerInsertMode = 'block' | 'inline'
|
export type ComposerInsertMode = 'block' | 'inline'
|
||||||
|
|
||||||
@ -23,8 +25,14 @@ interface InsertDetail {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InsertRefsDetail {
|
||||||
|
refs: InlineRefInput[]
|
||||||
|
target: ComposerTarget
|
||||||
|
}
|
||||||
|
|
||||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||||
const INSERT_EVENT = 'hermes:composer-insert'
|
const INSERT_EVENT = 'hermes:composer-insert'
|
||||||
|
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||||
|
|
||||||
let activeTarget: ComposerTarget = 'main'
|
let activeTarget: ComposerTarget = 'main'
|
||||||
|
|
||||||
@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
|
|||||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
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.
|
* Focus a composer input across React commit + browser focus restore.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -44,6 +44,7 @@ import {
|
|||||||
focusComposerInput,
|
focusComposerInput,
|
||||||
markActiveComposer,
|
markActiveComposer,
|
||||||
onComposerFocusRequest,
|
onComposerFocusRequest,
|
||||||
|
onComposerInsertRefsRequest,
|
||||||
onComposerInsertRequest
|
onComposerInsertRequest
|
||||||
} from './focus'
|
} from './focus'
|
||||||
import { HelpHint } from './help-hint'
|
import { HelpHint } from './help-hint'
|
||||||
@ -51,7 +52,12 @@ import { useAtCompletions } from './hooks/use-at-completions'
|
|||||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
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 { QueuePanel } from './queue-panel'
|
||||||
import {
|
import {
|
||||||
composerPlainText,
|
composerPlainText,
|
||||||
@ -431,7 +437,7 @@ export function ChatBar({
|
|||||||
requestMainFocus()
|
requestMainFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertInlineRefs = (refs: string[]) => {
|
const insertInlineRefs = (refs: InlineRefInput[]) => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@ -451,6 +457,19 @@ export function ChatBar({
|
|||||||
return true
|
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) => {
|
const selectSkinSlashCommand = (command: string) => {
|
||||||
draftRef.current = command
|
draftRef.current = command
|
||||||
aui.composer().setText(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'
|
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) {
|
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||||
if (!transfer) {
|
if (!transfer) {
|
||||||
return false
|
return false
|
||||||
@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
|||||||
return `@${kind}:${formatRefValue(rel)}`
|
return `@${kind}:${formatRefValue(rel)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
|
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||||
if (!refs.length) {
|
if (!refs.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const refsHtml = refs
|
const refsHtml = refs
|
||||||
.map(ref => {
|
.map(ref => {
|
||||||
|
if (typeof ref !== 'string') {
|
||||||
|
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||||
|
}
|
||||||
|
|
||||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||||
|
|
||||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
|
|
||||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||||
|
|
||||||
@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
|
|||||||
return formatRefValue(value)
|
return formatRefValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refChipHtml(kind: string, rawValue: string) {
|
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
|
||||||
const id = unquoteRef(rawValue)
|
const id = unquoteRef(rawValue)
|
||||||
const text = `@${kind}:${quoteRefValue(id)}`
|
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 id = unquoteRef(rawValue)
|
||||||
const text = `@${kind}:${quoteRefValue(id)}`
|
const text = `@${kind}:${quoteRefValue(id)}`
|
||||||
const chip = document.createElement('span')
|
const chip = document.createElement('span')
|
||||||
@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) {
|
|||||||
chip.dataset.refKind = kind
|
chip.dataset.refKind = kind
|
||||||
chip.className = DIRECTIVE_CHIP_CLASS
|
chip.className = DIRECTIVE_CHIP_CLASS
|
||||||
label.className = 'truncate'
|
label.className = 'truncate'
|
||||||
label.textContent = refLabel(id)
|
label.textContent = displayLabel || refLabel(id)
|
||||||
chip.append(directiveIconElement(kind), label)
|
chip.append(directiveIconElement(kind), label)
|
||||||
|
|
||||||
return chip
|
return chip
|
||||||
|
|||||||
@ -1,50 +1,71 @@
|
|||||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
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'
|
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 {
|
interface FileDropZoneOptions {
|
||||||
/** When false the zone ignores drags entirely. */
|
/** When false the zone ignores drags entirely. */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
onDropFiles: (files: DroppedFile[]) => void
|
onDropFiles: (files: DroppedFile[]) => void
|
||||||
|
onDropSession?: (session: SessionDragPayload) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
|
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||||
* keeps nested children from flickering the active state; `onDropCapture` clears
|
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||||
* it even when a nested target (the composer) handles the drop and stops
|
* active state; `onDropCapture` clears it even when a nested target (the
|
||||||
* propagation before our bubble-phase `onDrop` would fire.
|
* 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) {
|
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||||
const depth = useRef(0)
|
const depth = useRef(0)
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
depth.current = 0
|
depth.current = 0
|
||||||
setDragActive(false)
|
setDragKind(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDragEnter = useCallback(
|
const onDragEnter = useCallback(
|
||||||
(event: ReactDragEvent) => {
|
(event: ReactDragEvent) => {
|
||||||
if (!enabled || !hasFiles(event)) {
|
const kind = enabled ? dragKindOf(event) : null
|
||||||
|
|
||||||
|
if (!kind) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
depth.current += 1
|
depth.current += 1
|
||||||
setDragActive(true)
|
setDragKind(kind)
|
||||||
},
|
},
|
||||||
[enabled]
|
[enabled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onDragOver = useCallback(
|
const onDragOver = useCallback(
|
||||||
(event: ReactDragEvent) => {
|
(event: ReactDragEvent) => {
|
||||||
if (!enabled || !hasFiles(event)) {
|
if (!enabled || !dragKindOf(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
|
|||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: ReactDragEvent) => {
|
(event: ReactDragEvent) => {
|
||||||
if (!enabled || !hasFiles(event)) {
|
const kind = enabled ? dragKindOf(event) : null
|
||||||
|
|
||||||
|
if (!kind) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
|
if (kind === 'session') {
|
||||||
|
const session = readSessionDrag(event.dataTransfer)
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
onDropSession?.(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const files = extractDroppedFiles(event.dataTransfer)
|
const files = extractDroppedFiles(event.dataTransfer)
|
||||||
|
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
onDropFiles(files)
|
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 { cn } from '@/lib/utils'
|
||||||
import type { ComposerAttachment } from '@/store/composer'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { $pinnedSessionIds } from '@/store/layout'
|
import { $pinnedSessionIds } from '@/store/layout'
|
||||||
|
import { $gatewaySwapTarget } from '@/store/profile'
|
||||||
import {
|
import {
|
||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
$awaitingResponse,
|
$awaitingResponse,
|
||||||
@ -45,9 +46,10 @@ import { routeSessionId } from '../routes'
|
|||||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||||
|
|
||||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||||
|
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||||
import { ChatBar, ChatBarFallback } from './composer'
|
import { ChatBar, ChatBarFallback } from './composer'
|
||||||
import { requestComposerInsert } from './composer/focus'
|
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||||
import { droppedFileInlineRef } from './composer/inline-refs'
|
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||||
import type { ChatBarState } from './composer/types'
|
import type { ChatBarState } from './composer/types'
|
||||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||||
@ -178,6 +180,7 @@ export function ChatView({
|
|||||||
const currentProvider = useStore($currentProvider)
|
const currentProvider = useStore($currentProvider)
|
||||||
const freshDraftReady = useStore($freshDraftReady)
|
const freshDraftReady = useStore($freshDraftReady)
|
||||||
const gatewayState = useStore($gatewayState)
|
const gatewayState = useStore($gatewayState)
|
||||||
|
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||||
const gatewayOpen = gatewayState === 'open'
|
const gatewayOpen = gatewayState === 'open'
|
||||||
const introPersonality = useStore($introPersonality)
|
const introPersonality = useStore($introPersonality)
|
||||||
const introSeed = useStore($introSeed)
|
const introSeed = useStore($introSeed)
|
||||||
@ -306,7 +309,13 @@ export function ChatView({
|
|||||||
[currentCwd]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -370,7 +379,8 @@ export function ChatView({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
<ChatDropOverlay active={dragActive} />
|
<ChatDropOverlay kind={dragKind} />
|
||||||
|
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import type { SessionInfo } from '@/hermes'
|
import type { SessionInfo } from '@/hermes'
|
||||||
@ -87,6 +88,22 @@ export function SidebarSessionRow({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
data-working={isWorking ? 'true' : undefined}
|
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}
|
ref={ref}
|
||||||
style={style}
|
style={style}
|
||||||
{...rest}
|
{...rest}
|
||||||
@ -132,6 +149,7 @@ export function SidebarSessionRow({
|
|||||||
// out instead of being clipped by this handle's overflow-hidden.
|
// out instead of being clipped by this handle's overflow-hidden.
|
||||||
needsInput && 'overflow-visible'
|
needsInput && 'overflow-visible'
|
||||||
)}
|
)}
|
||||||
|
data-reorder-handle
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<SidebarRowDot
|
<SidebarRowDot
|
||||||
|
|||||||
@ -77,6 +77,10 @@ export function useGatewayBoot({
|
|||||||
let reconnecting = false
|
let reconnecting = false
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let reconnectAttempt = 0
|
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
|
// 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
|
// `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
|
// again" message once instead of silently looping the backoff against a
|
||||||
// ticket that can never succeed. Transport failures fall through to the
|
// ticket that can never succeed. Transport failures fall through to the
|
||||||
// backoff in the finally block below.
|
// backoff in the finally block below.
|
||||||
if (!cancelled && isGatewayReauthRequired(err)) {
|
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
|
||||||
|
reauthNotified = true
|
||||||
notifyError(err, 'Gateway sign-in required')
|
notifyError(err, 'Gateway sign-in required')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -188,6 +193,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
if (st === 'open') {
|
if (st === 'open') {
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
|
reauthNotified = false
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
// 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 { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
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]
|
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||||
|
|
||||||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
/** 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'],
|
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'],
|
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']
|
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
|
* 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. */
|
* muted-foreground text so chips read as quiet tags on any bubble color. */
|
||||||
export const DIRECTIVE_CHIP_CLASS =
|
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
|
* 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 CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||||
|
|
||||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
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'
|
'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()
|
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||||
|
|
||||||
return tail || id
|
return tail || id
|
||||||
|
|||||||
@ -87,6 +87,15 @@ export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => P
|
|||||||
let registeredTrigger: HapticTrigger | null = null
|
let registeredTrigger: HapticTrigger | null = null
|
||||||
let lastSelectionAt = 0
|
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) {
|
export function registerHapticTrigger(trigger: HapticTrigger | null) {
|
||||||
registeredTrigger = trigger
|
registeredTrigger = trigger
|
||||||
}
|
}
|
||||||
@ -106,6 +115,14 @@ export function triggerHaptic(intent: HapticIntent = 'selection') {
|
|||||||
lastSelectionAt = now
|
lastSelectionAt = now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recentFires = recentFires.filter(t => now - t < RATE_WINDOW)
|
||||||
|
|
||||||
|
if (recentFires.length >= RATE_LIMIT) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recentFires.push(now)
|
||||||
|
|
||||||
const config = HAPTIC_INTENTS[intent]
|
const config = HAPTIC_INTENTS[intent]
|
||||||
|
|
||||||
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
|
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
|
||||||
|
|||||||
@ -141,6 +141,11 @@ $activeGatewayProfile.subscribe(value => {
|
|||||||
_lastRoutedProfile = key
|
_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
|
let gatewaySwitch: Promise<void> | null = null
|
||||||
|
|
||||||
// Reconnect the single live gateway to `profile`'s backend if it isn't already
|
// 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 () => {
|
gatewaySwitch = (async () => {
|
||||||
const desktop = window.hermesDesktop
|
const desktop = window.hermesDesktop
|
||||||
const gateway = $gateway.get()
|
const gateway = $gateway.get()
|
||||||
@ -205,6 +211,7 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
|
|||||||
await gatewaySwitch
|
await gatewaySwitch
|
||||||
} finally {
|
} finally {
|
||||||
gatewaySwitch = null
|
gatewaySwitch = null
|
||||||
|
$gatewaySwapTarget.set(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -399,3 +399,124 @@ class TestShapePrecedence:
|
|||||||
_seed_modpack_sessions(db)
|
_seed_modpack_sessions(db)
|
||||||
result = json.loads(session_search(query=None, db=db)) # type: ignore
|
result = json.loads(session_search(query=None, db=db)) # type: ignore
|
||||||
assert result["mode"] == "browse"
|
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",)}
|
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:
|
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)."""
|
"""Return metadata for the most recent sessions (no LLM calls, no FTS5)."""
|
||||||
try:
|
try:
|
||||||
@ -387,15 +503,19 @@ def session_search(
|
|||||||
window: int = 5,
|
window: int = 5,
|
||||||
# Discovery shape
|
# Discovery shape
|
||||||
sort: str = None,
|
sort: str = None,
|
||||||
|
# Cross-profile (any shape)
|
||||||
|
profile: str = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Single-shape tool. Mode inferred from which args are set.
|
"""Single-shape tool. Mode inferred from which args are set.
|
||||||
|
|
||||||
Discovery: pass ``query``.
|
Discovery: pass ``query``.
|
||||||
Scroll: pass ``session_id`` + ``around_message_id``.
|
Scroll: pass ``session_id`` + ``around_message_id``.
|
||||||
|
Read: pass ``session_id`` (no anchor) — dumps the whole session.
|
||||||
Browse: pass nothing.
|
Browse: pass nothing.
|
||||||
|
|
||||||
Scroll wins over discovery when both are set — the agent has explicitly
|
Pass ``profile`` to read another profile's sessions (e.g. resolving an
|
||||||
asked for a slice of a known session.
|
``@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:
|
if db is None:
|
||||||
try:
|
try:
|
||||||
@ -406,6 +526,30 @@ def session_search(
|
|||||||
from hermes_state import format_session_db_unavailable
|
from hermes_state import format_session_db_unavailable
|
||||||
return tool_error(format_session_db_unavailable(), success=False)
|
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.
|
# 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:
|
if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None:
|
||||||
return _scroll(
|
return _scroll(
|
||||||
@ -416,6 +560,27 @@ def session_search(
|
|||||||
current_session_id=current_session_id,
|
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]
|
# Limit clamp [1, 10]
|
||||||
if not isinstance(limit, int):
|
if not isinstance(limit, int):
|
||||||
try:
|
try:
|
||||||
@ -465,7 +630,7 @@ SESSION_SEARCH_SCHEMA = {
|
|||||||
"Search past sessions stored in the local session DB, or scroll inside one. "
|
"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 "
|
"FTS5-backed retrieval over the SQLite message store. No LLM calls — every "
|
||||||
"shape returns actual messages from the DB.\n\n"
|
"shape returns actual messages from the DB.\n\n"
|
||||||
"THREE CALLING SHAPES\n\n"
|
"FOUR CALLING SHAPES\n\n"
|
||||||
" 1) DISCOVERY — pass `query`:\n"
|
" 1) DISCOVERY — pass `query`:\n"
|
||||||
" session_search(query=\"auth refactor\", limit=3)\n"
|
" session_search(query=\"auth refactor\", limit=3)\n"
|
||||||
" Runs FTS5, dedupes hits by session lineage, returns the top N sessions. "
|
" 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"
|
" - The boundary message appears in both windows — orientation marker.\n"
|
||||||
" - When messages_before or messages_after is < window, you're at the "
|
" - When messages_before or messages_after is < window, you're at the "
|
||||||
"start or end of the session.\n\n"
|
"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"
|
" session_search()\n"
|
||||||
" Returns recent sessions chronologically: titles, previews, timestamps. "
|
" Returns recent sessions chronologically: titles, previews, timestamps. "
|
||||||
"Use when the user asks \"what was I working on\" without naming a topic.\n\n"
|
"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."
|
"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": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
@ -594,6 +774,7 @@ registry.register(
|
|||||||
around_message_id=args.get("around_message_id"),
|
around_message_id=args.get("around_message_id"),
|
||||||
window=args.get("window", 5),
|
window=args.get("window", 5),
|
||||||
sort=args.get("sort"),
|
sort=args.get("sort"),
|
||||||
|
profile=args.get("profile"),
|
||||||
db=kw.get("db"),
|
db=kw.get("db"),
|
||||||
current_session_id=kw.get("current_session_id"),
|
current_session_id=kw.get("current_session_id"),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user