refactor(desktop): extract use-prompt-actions standalone helpers into utils

The usePromptActions hook is the textbook "god hook" AGENTS.md warns against.
As a first, safe slice, pull its module-level standalone helpers (no closure
over hook state) into a focused, testable use-prompt-actions-utils.ts sibling:

- error classifiers: isSessionNotFoundError, isSessionBusyError,
  isProviderSetupError, inlineErrorMessage
- session-busy retry: withSessionBusyRetry (+ its constants)
- attachment IO: base64FromDataUrl, imageFilenameFromPath,
  readImageForRemoteAttach, readFileDataUrlForAttach, friendlyRemoteAttachError
- misc: delay, isSessionIdCandidate, blobToDataUrl, renderCommandsCatalog,
  slashStatusText, appendText, visibleUserOrdinal, visibleUserIndexAtOrdinal,
  the _submitInFlight guard set, and the GatewayRequest type

Pure restructuring, no behavior change; the usePromptActions and
uploadComposerAttachment exports (and their import paths) are unchanged. Adds
unit tests for the pure helpers. use-prompt-actions.ts: 1,956 -> 1,772.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 01:38:58 -05:00
parent a81c5922a2
commit bde2dc1051
3 changed files with 360 additions and 206 deletions

View file

@ -0,0 +1,127 @@
import type { AppendMessage } from '@assistant-ui/react'
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
appendText,
base64FromDataUrl,
friendlyRemoteAttachError,
imageFilenameFromPath,
inlineErrorMessage,
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
slashStatusText,
visibleUserIndexAtOrdinal,
visibleUserOrdinal
} from './use-prompt-actions-utils'
describe('isSessionIdCandidate', () => {
it('accepts the timestamped and hex id forms', () => {
expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true)
expect(isSessionIdCandidate('a'.repeat(32))).toBe(true)
})
it('rejects arbitrary text', () => {
expect(isSessionIdCandidate('hello world')).toBe(false)
expect(isSessionIdCandidate('abc')).toBe(false)
})
})
describe('inlineErrorMessage', () => {
it('unwraps an electron remote-method error', () => {
expect(inlineErrorMessage(new Error("Error invoking remote method 'x': Error: boom"), 'fallback')).toBe('boom')
})
it('strips a leading Error: prefix', () => {
expect(inlineErrorMessage(new Error('Error: nope'), 'fallback')).toBe('nope')
})
it('falls back for non-error, non-string input', () => {
expect(inlineErrorMessage(undefined, 'fallback')).toBe('fallback')
})
})
describe('session error classifiers', () => {
it('detects not-found and busy errors', () => {
expect(isSessionNotFoundError(new Error('Session not found'))).toBe(true)
expect(isSessionBusyError(new Error('session busy'))).toBe(true)
expect(isSessionNotFoundError(new Error('other'))).toBe(false)
expect(isSessionBusyError(new Error('other'))).toBe(false)
})
})
describe('base64FromDataUrl', () => {
it('returns the part after the comma', () => {
expect(base64FromDataUrl('data:image/png;base64,AAAA')).toBe('AAAA')
})
it('returns empty when there is no comma', () => {
expect(base64FromDataUrl('nope')).toBe('')
})
})
describe('imageFilenameFromPath', () => {
it('takes the last path segment', () => {
expect(imageFilenameFromPath('/a/b/c.png')).toBe('c.png')
expect(imageFilenameFromPath('C:\\a\\b\\d.jpg')).toBe('d.jpg')
})
it('defaults when the path is empty', () => {
expect(imageFilenameFromPath('')).toBe('image.png')
})
})
describe('friendlyRemoteAttachError', () => {
it('rewrites a too-large error with the parsed cap', () => {
const err = friendlyRemoteAttachError(new Error('file is too large (20 bytes; limit 16777216 bytes)'), 'pic.png')
expect(err.message).toBe('pic.png is too large to upload to the remote gateway (max 16 MB).')
})
it('passes non-cap errors through', () => {
const original = new Error('something else')
expect(friendlyRemoteAttachError(original, 'pic.png')).toBe(original)
})
})
describe('slashStatusText', () => {
it('joins command and trimmed output', () => {
expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt')
})
it('omits empty output', () => {
expect(slashStatusText('/clear', ' ')).toBe('slash:/clear')
})
})
describe('appendText', () => {
it('concatenates text parts and trims', () => {
const message = {
content: [
{ type: 'text', text: ' a' },
{ type: 'text', text: 'b ' }
]
} as unknown as AppendMessage
expect(appendText(message)).toBe('ab')
})
})
describe('visible user ordinals', () => {
const messages = [
{ role: 'user', hidden: false },
{ role: 'assistant' },
{ role: 'user', hidden: true },
{ role: 'user', hidden: false }
] as ChatMessage[]
it('counts visible user messages before an index', () => {
expect(visibleUserOrdinal(messages, messages.length)).toBe(2)
})
it('maps an ordinal back to a message index, skipping hidden', () => {
expect(visibleUserIndexAtOrdinal(messages, 1)).toBe(3)
expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1)
})
})

View file

@ -0,0 +1,211 @@
import type { AppendMessage } from '@assistant-ui/react'
import { translateNow, type Translations } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/desktop-slash-commands'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function isSessionIdCandidate(value: string): boolean {
const trimmed = value.trim()
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
}
export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error(translateNow('desktop.audioReadFailed')))
}
})
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
reader.readAsDataURL(blob)
})
}
export function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return isProviderSetupErrorMessage(message)
}
export function inlineErrorMessage(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
export function isSessionNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session not found/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). It's a transient concurrency guard, never a user-facing error: a
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
// turn still surfaces eventually.
export const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
export const SESSION_BUSY_RETRY_INTERVAL_MS = 150
export function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
// Retry a gateway call across transient "session busy" so it never reaches the
// user — the turn settles within the deadline and the call lands.
export async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
for (;;) {
try {
return await call()
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
}
// Hard guard: at most one prompt.submit in flight per session. Every submit
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
// session whose first call hangs) let the SAME prompt launch several real turns
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
export const _submitInFlight = new Set<string>()
export function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
export function imageFilenameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
}
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
export async function readImageForRemoteAttach(
filePath: string
): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
export async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
export function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
export function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
.map(section => {
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
return [`${section.name}:`, ...rows].join('\n')
})
.join('\n\n')
const tail = [
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
}
export function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
export function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
.join('')
.trim()
}
export function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
let ordinal = 0
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]
if (message.role !== 'user' || message.hidden) {
continue
}
if (ordinal === targetOrdinal) {
return index
}
ordinal += 1
}
return -1
}

View file

@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
@ -19,13 +19,11 @@ import {
type DesktopActionId,
type DesktopPickerId,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearClarifyRequest } from '@/store/clarify'
import { openCommandPalettePage } from '@/store/command-palette'
@ -76,153 +74,32 @@ import type {
SlashExecResponse
} from '../../types'
import {
_submitInFlight,
appendText,
blobToDataUrl,
delay,
friendlyRemoteAttachError,
type GatewayRequest,
inlineErrorMessage,
isProviderSetupError,
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
readFileDataUrlForAttach,
readImageForRemoteAttach,
renderCommandsCatalog,
slashStatusText,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionBusyRetry
} from './use-prompt-actions-utils'
interface HandoffResult {
ok: boolean
error?: string
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isSessionIdCandidate(value: string): boolean {
const trimmed = value.trim()
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error(translateNow('desktop.audioReadFailed')))
}
})
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
reader.readAsDataURL(blob)
})
}
function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return isProviderSetupErrorMessage(message)
}
function inlineErrorMessage(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function isSessionNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session not found/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). It's a transient concurrency guard, never a user-facing error: a
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
// turn still surfaces eventually.
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
// Retry a gateway call across transient "session busy" so it never reaches the
// user — the turn settles within the deadline and the call lands.
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
for (;;) {
try {
return await call()
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
}
// Hard guard: at most one prompt.submit in flight per session. Every submit
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
// session whose first call hangs) let the SAME prompt launch several real turns
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
const _submitInFlight = new Set<string>()
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
function imageFilenameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
}
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
async function readImageForRemoteAttach(filePath: string): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
@ -350,67 +227,6 @@ interface SlashActionCtx {
sessionHint?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
.map(section => {
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
return [`${section.name}:`, ...rows].join('\n')
})
.join('\n\n')
const tail = [
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
}
function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
.join('')
.trim()
}
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
let ordinal = 0
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]
if (message.role !== 'user' || message.hidden) {
continue
}
if (ordinal === targetOrdinal) {
return index
}
ordinal += 1
}
return -1
}
interface RestoreMessageTarget {
text?: string
userOrdinal?: number | null