hermes-agent/apps/desktop/src/app/chat/composer/focus.ts
Brooklyn Nicholson 9dbd3c57d7 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.
2026-06-04 19:41:51 -05:00

125 lines
3.6 KiB
TypeScript

/**
* Composer focus + external-insert bus.
*
* Mutations from outside the composer (sidebar attach, drag drop, terminal
* Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes
* and routes the work back into its own ref/state.
*
* `dispatch` defers to a macrotask so synchronous click/keydown handlers
* (react-arborist row focus, picker `node.select()`) finish first and don't
* steal focus from the composer effect.
*/
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
target: ComposerTarget
}
interface InsertDetail {
mode: ComposerInsertMode
target: ComposerTarget
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'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
const dispatch = <T>(name: string, detail: T) => {
if (typeof window === 'undefined') {
return
}
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => {
const detail = (event as CustomEvent<T>).detail
if (detail) {
handler(detail)
}
}
window.addEventListener(name, listener)
return () => window.removeEventListener(name, listener)
}
export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerInsert = (
text: string,
{ mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
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.
*
* The triple-call survives:
* - sync: contenteditable already mounted
* - rAF: React just committed a `renderComposerContents` swap
* - 0ms: browser focus reclaim from a click target inside an external panel
*/
export const focusComposerInput = (el: HTMLElement | null) => {
if (!el) {
return
}
const focus = () => el.focus({ preventScroll: true })
focus()
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}