Files
hermes-agent/apps/desktop/src/lib/chat-runtime.ts
DavidMetcalfe 774b386518 fix(desktop): a pasted PR-comment deep link lands as text like every other URL
The composer's paste handler special-cased exactly one URL shape: a GitHub
PR-comment deep link (`…/pull/<n>#issuecomment-<id>` or `#discussion_r<id>`)
called `onAttachPrCommentUrl`, ran `preventDefault()`, and returned. The
clipboard payload never reached the editor — pasting into an empty composer
left it empty — and the URL existed only as an attachment pill above the
composer. Removing that pill lost the URL for good: nothing reinserted the
text and the path recorded no undo point.

Every URL now takes the ordinary link path. The renderer-side `review`
attachment machinery that only the interception fed goes with it:
`attachPrCommentUrl`, the `onAttachPrCommentUrl` prop/wiring/adapter entry,
the `review` attachment kind, `reviewCommentBlock` and `PR_COMMENT_URL_RE`
(verified by grep: no remaining consumer). The Electron `fetchPrComment` IPC
is removed in the next commit so it can be dropped independently.

Test: paste-url-is-text.test.tsx mounts the real ChatBar, fires a paste on
the contentEditable and pins the contract — the payload reaches the editor,
nothing is attached, the intercepting hook is never consulted.

Slim redo of the Desktop half of #112480 (cherry-picked from 74b31c5b79d92,
resolved onto the FloatingComposerSurface wrapper on main).
2026-09-16 17:47:26 -07:00

523 lines
16 KiB
TypeScript

import type { ThreadMessage } from '@assistant-ui/react'
import type { ModelOptionsResult } from '@hermes/shared'
import type { QuickModelOption } from '@/app/chat/composer/types'
import type { ClientSessionState } from '@/app/types'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalize } from '@/lib/text'
import type { ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
export { BUILTIN_PERSONALITIES } from '@/lib/personalities'
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: '',
model: '',
provider: '',
reasoningEffort: '',
serviceTier: '',
fast: false,
yolo: false,
personality: '',
busy: false,
awaitingResponse: false,
streamId: null,
sawAssistantPayload: false,
adoptedRunningTurn: false,
pendingBranchGroup: null,
interrupted: false,
interimBoundaryPending: false,
needsInput: false,
turnStartedAt: null,
turnLive: false,
usage: null
}
}
export function sessionTitle(session: SessionInfo): string {
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
}
/** What a session is called before it has been sent — and before its composer
* has been typed into, which is the only thing that can name it earlier. */
export const NEW_SESSION_TITLE = 'New 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
}
// IDs are content-derived (`kind:value`), not uuids, so upsertAttachment's
// exact-match dedupe only catches a re-attach when the raw value matches
// byte-for-byte. Normalize the value first so a trailing slash, a `\` path
// separator, etc. don't slip past dedupe as a "different" attachment.
function normalizeAttachmentValue(kind: ComposerAttachment['kind'], value: string): string {
const trimmed = value.trim()
if (kind === 'url') {
try {
// The WHATWG URL parser only collapses an EMPTY path to '/' (bare
// origin) — it does not treat '/a' and '/a/' as equivalent, so strip a
// trailing slash ourselves once the URL is otherwise canonicalized
// (scheme/host case, default ports, etc.).
return new URL(trimmed).toString().replace(/\/+$/, '')
} catch {
return trimmed
}
}
if (kind === 'file' || kind === 'folder' || kind === 'image') {
const posix = trimmed.replace(/\\/g, '/')
// Don't collapse a bare root ('/' or 'C:/') down to an empty string.
return posix.length > 1 ? posix.replace(/\/+$/, '') : posix
}
return trimmed
}
export function attachmentId(kind: ComposerAttachment['kind'], value: string): string {
return `${kind}:${normalizeAttachmentValue(kind, value)}`
}
export function pathLabel(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path
}
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
// Session switches / draft restores can leave undefined holes in the
// composer attachments array (see AttachmentList's filter(Boolean) + #49624).
// Every consumer funnels through here, so guard the chokepoint too.
if (!attachment) {
return 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
}
/**
* Display ref for the optimistic (in-flight) user bubble.
*
* Images prefer their bounded base64 thumbnail over a file path. A raw `data:`
* URL renders inline with zero network, while an `@image:<localpath>` ref would
* route through `/api/media` and can 403 in remote mode. Full-resolution bytes
* are loaded separately for the model and on-demand lightbox, not retained in
* the optimistic message.
*
* Everything else (files, folders, terminals, post-sync `@file:` refs) falls
* through to `attachmentDisplayText`.
*/
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
if (!attachment) {
return null
}
if (attachment.kind === 'image') {
if (attachment.thumbnailUrl?.startsWith('data:')) {
// The pill and the in-flight bubble render the bounded thumbnail. Full
// bytes are read separately for lightbox/download and model upload.
return attachment.thumbnailUrl
}
if (attachment.previewUrl?.startsWith('data:')) {
// Backward compatibility for drafts created by older shells without a
// separate thumbnail.
return attachment.previewUrl
}
// A newly attached image has no thumbnail while its queued resize is still
// pending. Do not fall through to @image:<path>: the optimistic bubble would
// fetch and paint the full source, recreating the freeze if Send wins the
// race. The model upload remains path/byte based and is unaffected.
return null
}
return attachmentDisplayText(attachment)
}
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 = normalize(value)
return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed
}
export function quickModelOptions(
data: ModelOptionsResult | 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)
}
// A message's display time. `timestamp` (Unix seconds) is authoritative when
// present. Without it we fall back to *now* rather than digging digits out of
// the id: message ids come in incompatible shapes — `assistant-<ms>`,
// `<seconds>-<i>-<role>`, session-style `20260728_184420_…` — and feeding any
// of them to `new Date()` (which reads ms) lands on the 1970 epoch, rendering
// as an absurd "20663d ago". A timestamp-less message is a freshly created
// optimistic/streaming one, so *now* is the right age anyway.
export function messageCreatedAt(message: Pick<ChatMessage, 'timestamp'>, nowMs = Date.now()): Date {
return typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) && message.timestamp > 0
? new Date(message.timestamp * 1000)
: new Date(nowMs)
}
export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
const role =
message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant'
const createdAt = messageCreatedAt(message)
// Reactions and the durable row id ride metadata.custom for every role — the
// established channel for per-message extras (attachmentRefs below).
const reactionMeta = {
...(message.rowId !== undefined ? { rowId: message.rowId } : {}),
...(message.reactions?.length ? { reactions: message.reactions } : {})
}
const timelineMeta =
typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) && message.timestamp > 0
? { timelineTimestamp: message.timestamp }
: {}
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 ?? [], ...reactionMeta, ...timelineMeta } }
} as ThreadMessage
}
if (role === 'system') {
const text = chatMessageText(message)
return {
id: message.id,
role,
content: [textPart(text)],
createdAt,
metadata: {
custom: {
...timelineMeta,
...(message.asyncResult ? { asyncResult: message.asyncResult } : {}),
...(message.asyncResultKind ? { asyncResultKind: message.asyncResultKind } : {})
}
}
} 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: [],
// Carries ChatMessage.interim to AssistantMessage's footer gate.
custom: {
...(message.interim ? { interim: true } : {}),
...timelineMeta,
...(message.completedAt !== undefined ? { timelineCompletedAt: message.completedAt } : {}),
...(message.durationS !== undefined ? { durationS: message.durationS } : {}),
// Structured failure layer for the error card (see lib/error-surface).
...(message.errorSurface ? { errorSurface: message.errorSurface } : {}),
...reactionMeta
}
}
} as ThreadMessage
}
export type ToolMergeCache = WeakMap<
ChatMessage,
{ merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] }
>
export function createToolMergeCache(): ToolMergeCache {
return new WeakMap()
}
// A settled assistant message with only tool calls — no prose, no reasoning.
// The model routinely emits a follow-up batch of calls as its own text-less
// message; on screen it looks like one continuous run, but assistant-ui can't
// group tool calls across a message boundary.
function isToolOnlyAssistant(message: ChatMessage): boolean {
return (
message.role === 'assistant' &&
!message.pending &&
!message.error &&
!message.hidden &&
message.parts.length > 0 &&
message.parts.every(part => part.type === 'tool-call')
)
}
/**
* Concatenate a tool-only follow-up message's parts onto its predecessor's,
* dropping any incoming `tool-call` part whose `toolCallId` the predecessor
* already carries. A repeated id here is the SAME call re-attached (structural
* carry-over re-adding a cached row's tool calls, or a live-turn projection
* that also exists as a committed row — #87857): folding both copies into one
* message manufactures the duplicate key that crashes assistant-ui's
* `useResources`, and renaming it would render the same call twice. Genuinely
* new calls in the same follow-up row are preserved.
*/
export function concatToolPartsUnique(
prevParts: readonly ChatMessagePart[],
nextParts: readonly ChatMessagePart[]
): ChatMessagePart[] {
const seen = new Set<string>()
for (const part of prevParts) {
if (part.type === 'tool-call' && part.toolCallId) {
seen.add(part.toolCallId)
}
}
const out = [...prevParts]
for (const part of nextParts) {
if (part.type === 'tool-call' && part.toolCallId) {
if (seen.has(part.toolCallId)) {
continue
}
seen.add(part.toolCallId)
}
out.push(part)
}
return out
}
/**
* Fold each settled tool-only assistant message into the preceding assistant
* message so its calls join that message's tool group (and can collapse into
* the auto-scrolling window). Render-only — never mutates the `$messages` store
* — and settle-only: pending messages are left alone, so a live turn is never
* merged/un-merged mid-stream. `cache` keys merged results by source identity,
* so a stable turn yields stable merged objects (no re-render churn).
*/
export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] {
const out: ChatMessage[] = []
for (const message of messages) {
const prev = out.at(-1)
if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) {
const cached = cache.get(message)
const merged =
cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts
? cached.merged
: {
...prev,
completedAt: [prev.completedAt, message.completedAt, ...message.parts.map(part => part.completedAt)]
.filter((value): value is number => value !== undefined)
.reduce<number | undefined>(
(latest, value) => (latest === undefined ? value : Math.max(latest, value)),
undefined
),
parts: concatToolPartsUnique(prev.parts, message.parts)
}
cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts })
out[out.length - 1] = merged
continue
}
out.push(message)
}
return out
}