hermes-agent/apps/desktop/src/lib/chat-runtime.ts
brooklyn! 0cbcc75935
fix(desktop): reliable composer message queue (#40221)
* fix(desktop): make composer message queue reliable

The queue felt 'dumb' because of three real bugs:

1. Drained-after-interrupt sends went silent. cancelRun sets
   interrupted:true and nothing reset it; submitPromptText's optimistic
   seed preserved it, and the message stream drops every delta while
   interrupted. So Send-now-while-busy and any interrupt+drain submitted
   the next turn into a muted session. Fix: a fresh submit is a new turn —
   seed interrupted:false.

2. Back-to-back queue drains stalled. The drain fires on the busy->false
   settle edge, but busyRef (synced from the busy store by a separate
   effect) can still read true on that same edge, so the drained send hit
   the busy guard, returned false, and the entry was never removed. Fix:
   fromQueue sends bypass the busyRef guard (the queue drain lock
   serializes them); the user path keeps the guard.

3. Double-enter-to-interrupt killed single non-queue turns. The hidden
   450ms timer meant a natural double-tap after sending stopped the agent.
   Fix: empty Enter while busy is a no-op; interrupting is explicit —
   Stop button or Esc.

Also: clean stop (no [interrupted] marker), Send-now works while busy
(promote + interrupt + auto-drain), settle on the interrupted completion
path. Adds regression tests and unblocks the prompt-actions suite by
completing its stale @/hermes mock.

* fix(desktop): float the queue panel as an overlay so the chat doesn't resize

The queue list rendered in-flow inside the composer root, so its height
fed --composer-measured-height (the composer rect drives the thread's
bottom padding + last-message clearance). Queuing a message grew that
rect and the whole chat visibly resized.

Anchor the panel out of flow above the composer (absolute bottom-full,
capped at 40vh with internal scroll). It no longer contributes to the
measured height, so the thread layout stays put and the list overlays the
(already faded) chat. Still collapsible via the panel's own
disclosure header.

* fix(desktop): queue panel collapsed by default + shared border with composer

- Default the queue disclosure to collapsed (compact 'N queued' pill)
  instead of expanded.
- Drop the gap and merge the panel into the composer: square bottom
  corners, no bottom border/radius, and overlap down by the Root's pt-2
  (-mb-2) so the panel's borderless bottom lands on the composer surface's
  top border — one continuous bordered shape.

* style(desktop): tighten queue panel padding

* style(desktop): trim queue-ux comments to house style

* style(desktop): drop 'Cursor' references from comments
2026-06-05 20:21:41 -05:00

334 lines
8.7 KiB
TypeScript

import type { ThreadMessage } from '@assistant-ui/react'
import type { QuickModelOption } from '@/app/chat/composer/types'
import type { ClientSessionState, CommandDispatchResponse } from '@/app/types'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages'
import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]
const THINKING_STATUS_PREFIX_RE =
/^\s*(?:(?:[^\s.]{1,16})\s+)?(?:processing|thinking|reasoning|analyzing|pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|computing|synthesizing|formulating|brainstorming)\.\.\.\s*/i
const EMPTY_THINKING_PLACEHOLDER_RE =
/\b(?:current rewritten thinking|next thinking to process|provide the thinking content|don't see any .*thinking)\b/i
export function createClientSessionState(
storedSessionId: string | null = null,
messages: ChatMessage[] = []
): ClientSessionState {
return {
storedSessionId,
messages,
branch: '',
cwd: '',
busy: false,
awaitingResponse: false,
streamId: null,
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false,
needsInput: false
}
}
export function sessionTitle(session: SessionInfo): string {
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
}
export function coerceGatewayText(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
if (Array.isArray(value)) {
return value
.map(item => {
if (typeof item === 'string') {
return item
}
if (item && typeof item === 'object') {
const row = item as Record<string, unknown>
if (typeof row.text === 'string') {
return row.text
}
if (typeof row.output_text === 'string') {
return row.output_text
}
}
return ''
})
.join('')
}
if (typeof value === 'object') {
const row = value as Record<string, unknown>
if (typeof row.text === 'string') {
return row.text
}
if (typeof row.output_text === 'string') {
return row.output_text
}
try {
return JSON.stringify(value)
} catch {
return ''
}
}
return String(value)
}
/**
* Normalize a reasoning/thinking text payload from the gateway.
*
* Only the leading status prefix (e.g. "Hermes is thinking...") and the
* obvious placeholder echoes are stripped. We deliberately do NOT trim
* the delta — reasoning streams as small chunks (often individual tokens
* with leading or trailing spaces), and trimming each chunk before
* concatenation collapses adjacent words together. Whitespace between
* tokens belongs to the data, not chrome.
*/
export function coerceThinkingText(value: unknown): string {
const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '')
return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw
}
export function isImageGenerationTool(name?: string): boolean {
return name === 'image_generate'
}
export function contextPath(path: string, cwd: string): string {
if (!cwd) {
return path
}
const normalizedCwd = cwd.endsWith('/') ? cwd : `${cwd}/`
return path.startsWith(normalizedCwd) ? path.slice(normalizedCwd.length) : path
}
export function attachmentId(kind: ComposerAttachment['kind'], value: string): string {
return `${kind}:${value}`
}
export function pathLabel(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
if (attachment.kind === 'terminal' && attachment.detail) {
return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\``
}
if (attachment.refText) {
return attachment.refText
}
if (attachment.kind === 'image') {
const id = attachment.detail || attachment.path || attachment.label
return id ? `@image:${formatRefValue(id)}` : null
}
return null
}
export function personalityNamesFromConfig(config: unknown): string[] {
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
const personalities = agent.personalities
return personalities && typeof personalities === 'object' && !Array.isArray(personalities)
? Object.keys(personalities as Record<string, unknown>)
: []
}
export function normalizePersonalityValue(value: string): string {
const trimmed = value.trim().toLowerCase()
return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed
}
export function parseSlashCommand(command: string) {
const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
}
export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== 'object') {
return null
}
const row = raw as Record<string, unknown>
const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
switch (row.type) {
case 'exec':
case 'plugin':
return { type: row.type, output: str(row.output) }
case 'alias':
return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null
case 'skill':
return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null
case 'send':
return typeof row.message === 'string' ? { type: 'send', message: row.message } : null
default:
return null
}
}
export function quickModelOptions(
data: ModelOptionsResponse | undefined,
currentProvider: string,
currentModel: string
): QuickModelOption[] {
const seen = new Set<string>()
const options: QuickModelOption[] = []
const providers = [...(data?.providers ?? [])].sort((a, b) => {
if (a.slug === currentProvider) {
return -1
}
if (b.slug === currentProvider) {
return 1
}
if (a.is_current) {
return -1
}
if (b.is_current) {
return 1
}
return 0
})
const add = (provider: string, providerName: string, model: string) => {
const key = `${provider}:${model}`
if (!model || seen.has(key)) {
return
}
seen.add(key)
options.push({ provider, providerName, model })
}
if (currentProvider && currentModel) {
add(currentProvider, currentProvider, currentModel)
}
for (const provider of providers) {
const models = [...(provider.models ?? [])].sort((a, b) => {
if (provider.slug === currentProvider && a === currentModel) {
return -1
}
if (provider.slug === currentProvider && b === currentModel) {
return 1
}
return 0
})
for (const model of models) {
add(provider.slug, provider.name, model)
}
if (options.length >= 8) {
break
}
}
return options.slice(0, 8)
}
export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
const role =
message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant'
const createdAt = message.timestamp
? new Date(message.timestamp * 1000)
: new Date(Number(message.id.match(/\d+/)?.[0]) || Date.now())
if (role === 'user') {
return {
id: message.id,
role,
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
attachments: [],
createdAt,
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
} as ThreadMessage
}
if (role === 'system') {
const text = chatMessageText(message)
return {
id: message.id,
role,
content: [textPart(text)],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
return {
id: message.id,
role,
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
createdAt,
status: message.error
? { type: 'incomplete', reason: 'error', error: message.error }
: message.pending
? { type: 'running' }
: { type: 'complete', reason: 'stop' },
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}