Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import type { CustomEndpointUpdate } from '@/types/hermes'
export const AITURK_API_BASE_URL = 'https://ai.turkservis.online/v1'
export const AITURK_ACCOUNT_URL = 'https://turkservis.online/panel'
export function aiturkEndpoint(apiKey: string, model = '', models?: string[]): CustomEndpointUpdate {
return {
id: 'turkservis',
name: 'TurkServis · AITURK',
base_url: AITURK_API_BASE_URL,
api_key: apiKey.trim(),
model,
discover_models: true,
make_default: true,
...(models?.length ? { models } : {})
}
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
const ESC = '\x1b'
describe('parseAnsi', () => {
it('returns a single default segment for plain text', () => {
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
})
it('returns nothing for an empty string', () => {
expect(parseAnsi('')).toEqual([])
})
it('parses a basic foreground color sequence and resets', () => {
const input = `${ESC}[31merror${ESC}[0m ok`
expect(parseAnsi(input)).toEqual([
{ bold: false, fg: 'red', text: 'error' },
{ bold: false, fg: null, text: ' ok' }
])
})
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
const input = `${ESC}[1mloud${ESC}[22m quiet`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: null, text: 'loud' },
{ bold: false, fg: null, text: ' quiet' }
])
})
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'both' },
{ bold: true, fg: null, text: 'bold-only' }
])
})
it('handles bright colors via the 90-97 range', () => {
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
})
it('coalesces adjacent runs with the same style', () => {
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
})
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
// they never bleed into the visible segment text.
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('orange')
})
it('skips truecolor (38;2;r;g;b) trailing args', () => {
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
expect(segments).toHaveLength(1)
expect(segments[0].fg).toBe(null)
expect(segments[0].text).toBe('rgb')
})
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
})
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
const input = `${ESC}[1;31mfoo${ESC}[mbar`
expect(parseAnsi(input)).toEqual([
{ bold: true, fg: 'red', text: 'foo' },
{ bold: false, fg: null, text: 'bar' }
])
})
})
describe('hasAnsiCodes', () => {
it('returns false for plain text', () => {
expect(hasAnsiCodes('hello world')).toBe(false)
})
it('returns true when any CSI introducer is present', () => {
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
})
})
describe('ansiColorClass', () => {
it('returns a non-empty Tailwind class string for every supported color', () => {
const colors = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'bright-black',
'bright-red',
'bright-green',
'bright-yellow',
'bright-blue',
'bright-magenta',
'bright-cyan',
'bright-white'
] as const
for (const color of colors) {
expect(ansiColorClass(color)).toMatch(/\S/)
}
})
})
+186
View File
@@ -0,0 +1,186 @@
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
// cards. Only handles the SGR codes that show up in practice (color, bold,
// reset); cursor motions and other CSI sequences are dropped silently.
//
// Returns a flat array of styled segments so callers can render them as
// React spans without each consumer having to re-implement the parser.
export interface AnsiSegment {
bold: boolean
/** Tailwind text-color class or null for the default foreground. */
fg: AnsiColor | null
text: string
}
export type AnsiColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'bright-black'
| 'bright-red'
| 'bright-green'
| 'bright-yellow'
| 'bright-blue'
| 'bright-magenta'
| 'bright-cyan'
| 'bright-white'
const FG_BY_CODE: Record<number, AnsiColor> = {
30: 'black',
31: 'red',
32: 'green',
33: 'yellow',
34: 'blue',
35: 'magenta',
36: 'cyan',
37: 'white',
90: 'bright-black',
91: 'bright-red',
92: 'bright-green',
93: 'bright-yellow',
94: 'bright-blue',
95: 'bright-magenta',
96: 'bright-cyan',
97: 'bright-white'
}
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
// final bytes are matched and consumed so they don't leak into the rendered
// text. Range covers the common CSI command set (A-Z / a-z / @).
// eslint-disable-next-line no-control-regex
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
// eslint-disable-next-line no-control-regex
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
export function parseAnsi(input: string): AnsiSegment[] {
if (!input) {
return []
}
// Strip non-CSI escapes upfront — none of them carry text we want to keep
// and CSI_RE wouldn't match them.
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
const segments: AnsiSegment[] = []
let cursor = 0
let bold = false
let fg: AnsiColor | null = null
const pushText = (text: string) => {
if (!text) {
return
}
const last = segments.at(-1)
if (last && last.bold === bold && last.fg === fg) {
last.text += text
return
}
segments.push({ bold, fg, text })
}
CSI_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = CSI_RE.exec(cleaned)) !== null) {
const start = match.index
if (start > cursor) {
pushText(cleaned.slice(cursor, start))
}
if (match[2] === 'm') {
const codes = match[1]
.split(';')
.map(part => (part === '' ? 0 : Number(part)))
.filter(value => Number.isFinite(value))
for (let i = 0; i < codes.length; i += 1) {
const code = codes[i]
if (code === 0) {
bold = false
fg = null
} else if (code === 1) {
bold = true
} else if (code === 22) {
bold = false
} else if (code === 39) {
fg = null
} else if (code in FG_BY_CODE) {
fg = FG_BY_CODE[code]
} else if (code === 38) {
// 256-color / truecolor — skip the trailing args we don't render.
if (codes[i + 1] === 5) {
i += 2
} else if (codes[i + 1] === 2) {
i += 4
}
}
// Background colors (40-47, 100-107) and effects we don't render are
// intentionally ignored — the segment keeps the prior bold/fg state.
}
}
cursor = CSI_RE.lastIndex
}
if (cursor < cleaned.length) {
pushText(cleaned.slice(cursor))
}
return segments
}
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
// disappear into the surface.
black: 'text-zinc-700 dark:text-zinc-300',
red: 'text-red-700 dark:text-red-300',
green: 'text-emerald-700 dark:text-emerald-300',
yellow: 'text-amber-700 dark:text-amber-300',
blue: 'text-blue-700 dark:text-blue-300',
magenta: 'text-fuchsia-700 dark:text-fuchsia-300',
cyan: 'text-cyan-700 dark:text-cyan-300',
white: 'text-zinc-600 dark:text-zinc-200',
'bright-black': 'text-zinc-500 dark:text-zinc-400',
'bright-red': 'text-rose-600 dark:text-rose-300',
'bright-green': 'text-emerald-600 dark:text-emerald-200',
'bright-yellow': 'text-amber-600 dark:text-amber-200',
'bright-blue': 'text-sky-600 dark:text-sky-300',
'bright-magenta': 'text-pink-600 dark:text-pink-300',
'bright-cyan': 'text-teal-600 dark:text-teal-200',
'bright-white': 'text-zinc-500 dark:text-zinc-100'
}
export function ansiColorClass(color: AnsiColor): string {
return TAILWIND_BY_COLOR[color]
}
/** Returns true if the input contains at least one CSI sequence. Cheap check
* so callers can skip the parser for plain-ASCII output. */
export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
/** Remove all ANSI escape sequences, returning plain text. Use when output is
* rendered as text (e.g. chat system messages) rather than styled segments —
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
export function stripAnsi(input: string): string {
if (!input) {
return input
}
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect'
const HTML_DOC = `<!doctype html>
<html>
<head><title>Pomodoro Timer</title></head>
<body>
<h1>Pomodoro</h1>
<script>let t = 25 * 60; setInterval(() => { t -= 1 }, 1000)</script>
</body>
</html>`
function longCode(lines: number): string {
return Array.from(
{ length: lines },
(_, i) => `export function helper${i}(value: number) { return value * ${i} }`
).join('\n')
}
describe('detectArtifact', () => {
it('promotes a full html document', () => {
const detection = detectArtifact('html', HTML_DOC)
expect(detection).not.toBeNull()
expect(detection?.kind).toBe('html')
expect(detection?.title).toBe('Pomodoro Timer')
})
it('falls back to h1 when the document has no title tag', () => {
const doc = `<!doctype html><html><body><h1>Budget <em>Dashboard</em></h1>${'<div>x</div>'.repeat(30)}</body></html>`
expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard')
})
it('ignores a small html snippet', () => {
expect(detectArtifact('html', '<div class="chip">hello</div>')).toBeNull()
})
it('ignores small svg fences (inline embed owns them)', () => {
expect(detectArtifact('svg', '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>')).toBeNull()
})
it('promotes a large svg', () => {
const svg = `<svg viewBox="0 0 100 100"><title>Org Chart</title>${'<rect x="1" y="2" width="3" height="4"/>'.repeat(80)}</svg>`
const detection = detectArtifact('svg', svg)
expect(detection?.kind).toBe('svg')
expect(detection?.title).toBe('Org Chart')
})
it('keeps short code inline', () => {
expect(detectArtifact('python', 'print("hi")')).toBeNull()
})
it('promotes long code and derives a declaration title', () => {
const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}`
const detection = detectArtifact('typescript', code)
expect(detection?.kind).toBe('code')
expect(detection?.title).toBe('buildDashboard')
})
it('prefers a filename comment for the title', () => {
const code = `# server.py\n${longCode(60)
.replace(/export function/g, 'def')
.replace(/\{|\}/g, '')}`
const detection = detectArtifact('python', code)
expect(detection?.kind).toBe('code')
expect(detection?.title).toBe('server.py')
})
it('never promotes prose-ish or terminal fences', () => {
expect(detectArtifact('text', longCode(80))).toBeNull()
expect(detectArtifact('diff', longCode(80))).toBeNull()
expect(detectArtifact('markdown', longCode(80))).toBeNull()
expect(detectArtifact('mermaid', longCode(80))).toBeNull()
})
})
describe('artifactSlug', () => {
it('is stable across regenerations of the same artifact', () => {
const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
expect(a).toBe(b)
expect(a).toContain('html')
})
it('distinguishes different titles', () => {
expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe(
artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' })
)
})
it('handles empty/symbol-only titles', () => {
expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled')
})
})
describe('artifactContentHash', () => {
it('is deterministic and content-sensitive', () => {
expect(artifactContentHash('abc')).toBe(artifactContentHash('abc'))
expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd'))
})
})
describe('artifactDownloadName', () => {
it('keeps an existing extension', () => {
expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py')
})
it('appends by kind and language', () => {
expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html')
expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg')
expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts')
expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
/**
* Artifact detection — decides when a fenced block in an assistant message is
* substantial, self-contained content that deserves an artifact card (opening
* in the right rail) instead of an inline code block.
*
* Pure and cheap: it runs per streaming delta on the growing fence body, so
* everything here is a bounded regex scan or a line count. No store access.
*/
export type ArtifactKind = 'code' | 'html' | 'svg'
export interface ArtifactDetection {
kind: ArtifactKind
/** Sanitized fence language ('' possible for html/svg detected by shape). */
language: string
/** Human title derived from the content (html <title>, svg <title>, a named
* declaration for code). Falls back to a kind/language label. */
title: string
}
// A fence only becomes an artifact once it is unambiguously a document (html),
// a large standalone graphic (svg), or long enough that inlining it would
// drown the conversation (code). Small snippets stay inline code cards.
const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i
const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i
const HTML_DOC_MIN_CHARS = 160
const HTML_FRAGMENT_MIN_CHARS = 1200
const SVG_MIN_CHARS = 2000
const CODE_MIN_LINES = 48
const CODE_MIN_CHARS = 3000
const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml'])
// Languages whose fences are never artifacts: prose-ish, terminal output, and
// the fences already owned by richer renderers (mermaid diagrams, small svg).
const NON_ARTIFACT_LANGUAGES = new Set([
'',
'console',
'diff',
'log',
'logs',
'markdown',
'md',
'mermaid',
'output',
'patch',
'plain',
'plaintext',
'shell-session',
'stdout',
'text',
'txt'
])
function countLines(text: string): number {
let lines = 1
let index = text.indexOf('\n')
while (index !== -1) {
lines += 1
index = text.indexOf('\n', index + 1)
}
return lines
}
function stripTags(value: string): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function titleFromTag(content: string, tag: 'h1' | 'title'): string {
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content)
return match ? stripTags(match[1] || '').slice(0, 80) : ''
}
const CODE_DECLARATION_RE =
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/
// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the
// first meaningful line — the de-facto LLM convention for naming a file.
const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i
function codeTitle(language: string, content: string): string {
const head = content.slice(0, 2000)
const fileName = FILENAME_COMMENT_RE.exec(head)?.[1]
if (fileName) {
return fileName
}
const declaration = CODE_DECLARATION_RE.exec(head)?.[1]
if (declaration) {
return declaration
}
return language
}
export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null {
const trimmed = (code ?? '').trim()
if (!trimmed) {
return null
}
const clean = sanitizeLanguageTag(language || '')
if (HTML_LANGUAGES.has(clean)) {
const isDocument = HTML_DOC_RE.test(trimmed)
if (
(isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) ||
(!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed))
) {
return {
kind: 'html',
language: clean,
title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML'
}
}
return null
}
if (clean === 'svg') {
// Small svg fences keep their inline embed (svg-embed.tsx); only a large
// standalone graphic graduates into an artifact tab.
if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) {
return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' }
}
return null
}
if (NON_ARTIFACT_LANGUAGES.has(clean)) {
return null
}
if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) {
return null
}
if (isLikelyProseCodeBlock(clean, trimmed)) {
return null
}
return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) }
}
/** Stable identity slug for versioning: the same (kind, title, language) in a
* session is treated as one artifact the model iterates on. */
export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string {
const title = detection.title
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
return `${detection.kind}:${detection.language}:${title || 'untitled'}`
}
/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */
export function artifactContentHash(content: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < content.length; i += 1) {
hash ^= content.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
bash: '.sh',
c: '.c',
cpp: '.cpp',
csharp: '.cs',
css: '.css',
go: '.go',
htm: '.html',
html: '.html',
java: '.java',
javascript: '.js',
js: '.js',
json: '.json',
jsx: '.jsx',
kotlin: '.kt',
php: '.php',
py: '.py',
python: '.py',
rb: '.rb',
rs: '.rs',
ruby: '.rb',
rust: '.rs',
sh: '.sh',
sql: '.sql',
svg: '.svg',
swift: '.swift',
toml: '.toml',
ts: '.ts',
tsx: '.tsx',
typescript: '.ts',
xhtml: '.html',
xml: '.xml',
yaml: '.yaml',
yml: '.yaml'
}
export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string {
const base =
title
.replace(/[^\p{L}\p{N}._ -]+/gu, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'artifact'
if (/\.[a-z0-9]{1,8}$/i.test(base)) {
return base
}
const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt'
return `${base}${ext}`
}
+37
View File
@@ -0,0 +1,37 @@
// One lazily-created AudioContext for the app's synthesized UI cues (wake
// chime, turn-completion sound, thinking blips). Browsers cap how many
// contexts a page may open, and these cues are short oscillator bursts that
// need no isolation from each other, so they share one instead of each holding
// its own. Nothing closes it — it lives for the window.
let ctx: AudioContext | null = null
/** The shared AudioContext, or null where WebAudio is unavailable. */
export function getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') {
return null
}
try {
if (!ctx) {
const Ctor =
window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
}
// Autoplay policies can leave the context suspended until a gesture; a
// resume() here recovers it once the user has interacted with the window.
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { resolveBrandIcon } from './brand-icon'
describe('resolveBrandIcon', () => {
it('resolves a registrable domain to a brand glyph', () => {
expect(resolveBrandIcon('github.com')).toBeTruthy()
expect(resolveBrandIcon('gitlab.com')).toBeTruthy()
})
it('ignores case and a leading www.', () => {
const github = resolveBrandIcon('github.com')
expect(resolveBrandIcon('WWW.GitHub.com')).toBe(github)
})
it('inherits the parent brand on subdomains', () => {
const github = resolveBrandIcon('github.com')
expect(resolveBrandIcon('gist.github.com')).toBe(github)
expect(resolveBrandIcon('api.github.com')).toBe(github)
})
it('prefers the longest matching suffix over its parent', () => {
const google = resolveBrandIcon('google.com')
const docs = resolveBrandIcon('docs.google.com')
expect(docs).toBeTruthy()
expect(docs).not.toBe(google)
})
it('returns null for unknown hosts and bare labels', () => {
expect(resolveBrandIcon('example.com')).toBeNull()
expect(resolveBrandIcon('localhost')).toBeNull()
expect(resolveBrandIcon('')).toBeNull()
})
it('never matches on a bare public suffix', () => {
// `github.io` is a real entry; a lone `io` must not inherit it.
expect(resolveBrandIcon('io')).toBeNull()
expect(resolveBrandIcon('com')).toBeNull()
})
})
+429
View File
@@ -0,0 +1,429 @@
import {
SiAnthropic,
SiArchlinux,
SiArxiv,
SiAsana,
SiAtlassian,
SiBehance,
SiBitbucket,
SiBlender,
SiBluesky,
SiBun,
SiBuymeacoffee,
SiCircleci,
SiClaude,
SiCloudflare,
SiCodeberg,
SiCodecov,
SiCodesandbox,
SiConfluence,
SiCoursera,
SiCrunchbase,
SiCursor,
SiDatadog,
SiDebian,
SiDeno,
SiDevdotto,
SiDigitalocean,
SiDiscord,
SiDjango,
SiDocker,
SiDropbox,
SiElectron,
SiEslint,
SiExcalidraw,
SiFacebook,
SiFastapi,
SiFigma,
SiFirebase,
SiFlask,
SiForgejo,
SiGhost,
SiGitea,
SiGithub,
SiGitlab,
SiGmail,
SiGo,
SiGodotengine,
SiGoodreads,
SiGoogle,
SiGoogledocs,
SiGoogledrive,
SiGooglegemini,
SiGooglemaps,
SiGooglescholar,
SiGrafana,
SiGraphql,
SiHashnode,
SiHomebrew,
SiHuggingface,
SiImdb,
SiInstagram,
SiInternetarchive,
SiItchdotio,
SiJenkins,
SiJira,
SiJupyter,
SiKaggle,
SiKofi,
SiKotlin,
SiKubernetes,
SiLaravel,
SiLeetcode,
SiLinear,
SiMastodon,
SiMdnwebdocs,
SiMedium,
SiMiro,
SiMistralai,
SiMongodb,
SiNetflix,
SiNetlify,
SiNodedotjs,
SiNotion,
SiNpm,
SiNvidia,
SiObsidian,
SiOllama,
SiOpenrouter,
SiOpenstreetmap,
SiOverleaf,
SiPatreon,
SiPaypal,
SiPerplexity,
SiPhp,
SiPinterest,
SiPnpm,
SiPostgresql,
SiPostman,
SiPrisma,
SiProducthunt,
SiPypi,
SiPython,
SiPytorch,
SiQuora,
SiRailway,
SiRaspberrypi,
SiRaycast,
SiReact,
SiReadthedocs,
SiReddit,
SiRedis,
SiRender,
SiReplicate,
SiReplit,
SiResearchgate,
SiRuby,
SiRubyonrails,
SiRust,
SiScikitlearn,
SiSemanticscholar,
SiSentry,
SiServerfault,
SiShopify,
SiSnyk,
SiSoundcloud,
SiSourcehut,
SiSpotify,
SiStackblitz,
SiStackexchange,
SiStackoverflow,
SiSteam,
SiStorybook,
SiStripe,
SiSubstack,
SiSupabase,
SiSuperuser,
SiSwift,
SiTailwindcss,
SiTauri,
SiTelegram,
SiTensorflow,
SiThreedotjs,
SiTiktok,
SiTldraw,
SiTrello,
SiTurborepo,
SiTwitch,
SiTypescript,
SiUbuntu,
SiUdemy,
SiUnity,
SiUnsplash,
SiVercel,
SiVimeo,
SiVite,
SiWarp,
SiWebflow,
SiWeightsandbiases,
SiWikipedia,
SiWindsurf,
SiWordpress,
SiX,
SiYcombinator,
SiYelp,
SiYoutube,
SiZedindustries,
SiZoom,
SiZotero
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
// Simple Icons components accept a `title` prop on top of the usual SVG props.
// It matters: they always render a <title> element, defaulting to the brand
// name, so callers need to be able to blank it out (see `LinkBrandIcon`).
export type BrandIcon = ComponentType<SVGProps<SVGSVGElement> & { title?: string }>
// simpleicons.org brand marks keyed by registrable domain. Lookup walks the
// host's suffixes (see `resolveBrandIcon`), so one `github.com` entry also
// covers `gist.github.com` and `api.github.com` — only list a subdomain when it
// belongs to a *different* brand than its parent.
//
// Some brands are absent on purpose: Simple Icons has removed Slack, LinkedIn,
// OpenAI, Amazon, and CodePen at their owners' request, so those hosts fall
// through to no glyph rather than a lookalike.
const BRAND_ICONS: Record<string, BrandIcon> = {
// Code hosting & package registries
'github.com': SiGithub,
'github.io': SiGithub,
'githubusercontent.com': SiGithub,
'gitlab.com': SiGitlab,
'gitlab.io': SiGitlab,
'bitbucket.org': SiBitbucket,
'codeberg.org': SiCodeberg,
'gitea.com': SiGitea,
'gitea.io': SiGitea,
'forgejo.org': SiForgejo,
'sr.ht': SiSourcehut,
'npmjs.com': SiNpm,
'pypi.org': SiPypi,
'crates.io': SiRust,
'huggingface.co': SiHuggingface,
'hf.co': SiHuggingface,
// Q&A, docs & reference
'stackoverflow.com': SiStackoverflow,
'stackexchange.com': SiStackexchange,
'serverfault.com': SiServerfault,
'superuser.com': SiSuperuser,
'developer.mozilla.org': SiMdnwebdocs,
'readthedocs.io': SiReadthedocs,
'readthedocs.org': SiReadthedocs,
'wikipedia.org': SiWikipedia,
'archive.org': SiInternetarchive,
// Research
'arxiv.org': SiArxiv,
'semanticscholar.org': SiSemanticscholar,
'scholar.google.com': SiGooglescholar,
'researchgate.net': SiResearchgate,
'zotero.org': SiZotero,
'overleaf.com': SiOverleaf,
// AI
'anthropic.com': SiAnthropic,
'claude.ai': SiClaude,
'gemini.google.com': SiGooglegemini,
'perplexity.ai': SiPerplexity,
'openrouter.ai': SiOpenrouter,
'mistral.ai': SiMistralai,
'ollama.com': SiOllama,
'replicate.com': SiReplicate,
'wandb.ai': SiWeightsandbiases,
'kaggle.com': SiKaggle,
// Social
'x.com': SiX,
'twitter.com': SiX,
't.co': SiX,
'reddit.com': SiReddit,
'redd.it': SiReddit,
'news.ycombinator.com': SiYcombinator,
'bsky.app': SiBluesky,
'mastodon.social': SiMastodon,
'joinmastodon.org': SiMastodon,
'facebook.com': SiFacebook,
'instagram.com': SiInstagram,
'tiktok.com': SiTiktok,
'pinterest.com': SiPinterest,
'discord.com': SiDiscord,
'discord.gg': SiDiscord,
't.me': SiTelegram,
'telegram.org': SiTelegram,
'producthunt.com': SiProducthunt,
'crunchbase.com': SiCrunchbase,
'quora.com': SiQuora,
// Writing & blogs
'medium.com': SiMedium,
'substack.com': SiSubstack,
'dev.to': SiDevdotto,
'hashnode.dev': SiHashnode,
'hashnode.com': SiHashnode,
'wordpress.com': SiWordpress,
'wordpress.org': SiWordpress,
'ghost.org': SiGhost,
// Media
'youtube.com': SiYoutube,
'youtu.be': SiYoutube,
'vimeo.com': SiVimeo,
'twitch.tv': SiTwitch,
'soundcloud.com': SiSoundcloud,
'spotify.com': SiSpotify,
'netflix.com': SiNetflix,
'imdb.com': SiImdb,
'goodreads.com': SiGoodreads,
'unsplash.com': SiUnsplash,
'behance.net': SiBehance,
'store.steampowered.com': SiSteam,
'itch.io': SiItchdotio,
// Product & project tools
'linear.app': SiLinear,
'notion.so': SiNotion,
'notion.site': SiNotion,
'figma.com': SiFigma,
'atlassian.net': SiAtlassian,
'atlassian.com': SiJira,
'confluence.com': SiConfluence,
'asana.com': SiAsana,
'trello.com': SiTrello,
'obsidian.md': SiObsidian,
'miro.com': SiMiro,
'excalidraw.com': SiExcalidraw,
'tldraw.com': SiTldraw,
'zoom.us': SiZoom,
// Google
'google.com': SiGoogle,
'goo.gl': SiGoogle,
'docs.google.com': SiGoogledocs,
'drive.google.com': SiGoogledrive,
'mail.google.com': SiGmail,
'maps.google.com': SiGooglemaps,
'openstreetmap.org': SiOpenstreetmap,
// Hosting & infra
'vercel.com': SiVercel,
'vercel.app': SiVercel,
'netlify.com': SiNetlify,
'netlify.app': SiNetlify,
'cloudflare.com': SiCloudflare,
'pages.dev': SiCloudflare,
'workers.dev': SiCloudflare,
'railway.app': SiRailway,
'render.com': SiRender,
'digitalocean.com': SiDigitalocean,
'firebase.google.com': SiFirebase,
'supabase.com': SiSupabase,
'docker.com': SiDocker,
'kubernetes.io': SiKubernetes,
'grafana.com': SiGrafana,
'datadoghq.com': SiDatadog,
'sentry.io': SiSentry,
'snyk.io': SiSnyk,
'codecov.io': SiCodecov,
'circleci.com': SiCircleci,
'jenkins.io': SiJenkins,
// Languages, frameworks & tooling
'python.org': SiPython,
'nodejs.org': SiNodedotjs,
'react.dev': SiReact,
'typescriptlang.org': SiTypescript,
'tailwindcss.com': SiTailwindcss,
'vite.dev': SiVite,
'vitejs.dev': SiVite,
'rust-lang.org': SiRust,
'go.dev': SiGo,
'golang.org': SiGo,
'ruby-lang.org': SiRuby,
'rubyonrails.org': SiRubyonrails,
'php.net': SiPhp,
'laravel.com': SiLaravel,
'djangoproject.com': SiDjango,
'flask.palletsprojects.com': SiFlask,
'fastapi.tiangolo.com': SiFastapi,
'swift.org': SiSwift,
'kotlinlang.org': SiKotlin,
'deno.com': SiDeno,
'bun.sh': SiBun,
'pnpm.io': SiPnpm,
'turborepo.com': SiTurborepo,
'eslint.org': SiEslint,
'storybook.js.org': SiStorybook,
'graphql.org': SiGraphql,
'prisma.io': SiPrisma,
'postgresql.org': SiPostgresql,
'redis.io': SiRedis,
'mongodb.com': SiMongodb,
'pytorch.org': SiPytorch,
'tensorflow.org': SiTensorflow,
'scikit-learn.org': SiScikitlearn,
'jupyter.org': SiJupyter,
'threejs.org': SiThreedotjs,
'blender.org': SiBlender,
'godotengine.org': SiGodotengine,
'unity.com': SiUnity,
'electronjs.org': SiElectron,
'tauri.app': SiTauri,
'brew.sh': SiHomebrew,
'archlinux.org': SiArchlinux,
'debian.org': SiDebian,
'ubuntu.com': SiUbuntu,
'raspberrypi.com': SiRaspberrypi,
'nvidia.com': SiNvidia,
// Dev environments & editors
'codesandbox.io': SiCodesandbox,
'stackblitz.com': SiStackblitz,
'replit.com': SiReplit,
'cursor.com': SiCursor,
'windsurf.com': SiWindsurf,
'zed.dev': SiZedindustries,
'warp.dev': SiWarp,
'raycast.com': SiRaycast,
'postman.com': SiPostman,
// Commerce, learning & the rest
'stripe.com': SiStripe,
'paypal.com': SiPaypal,
'patreon.com': SiPatreon,
'ko-fi.com': SiKofi,
'buymeacoffee.com': SiBuymeacoffee,
'shopify.com': SiShopify,
'webflow.com': SiWebflow,
'dropbox.com': SiDropbox,
'leetcode.com': SiLeetcode,
'coursera.org': SiCoursera,
'udemy.com': SiUdemy,
'yelp.com': SiYelp
}
// Resolve a hostname to its brand glyph, walking suffixes so subdomains inherit
// their parent's mark (`api.github.com` → `github.com`). Longest match wins, so
// a subdomain entry like `docs.google.com` beats the `google.com` fallback. The
// bare TLD is never tested.
export function resolveBrandIcon(hostname: string): BrandIcon | null {
const host = hostname
.trim()
.toLowerCase()
.replace(/^www\./, '')
if (!host.includes('.')) {
return null
}
const parts = host.split('.')
for (let i = 0; i < parts.length - 1; i += 1) {
const icon = BRAND_ICONS[parts.slice(i).join('.')]
if (icon) {
return icon
}
}
return null
}
+157
View File
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installWindowStateBridge, type WindowStateBridge } from '../test/window-state'
import { createBudgetedLoop } from './budgeted-loop'
let windowState: WindowStateBridge
function installRaf() {
let nextId = 1
const frames = new Map<number, FrameRequestCallback>()
const request = vi.fn((callback: FrameRequestCallback) => {
const id = nextId++
frames.set(id, callback)
return id
})
const cancel = vi.fn((id: number) => {
frames.delete(id)
})
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
return {
pending: () => frames.size,
runNext: (now: number) => {
const next = frames.entries().next().value
if (!next) {
throw new Error('No pending RAF')
}
const [id, callback] = next
frames.delete(id)
callback(now)
}
}
}
describe('createBudgetedLoop', () => {
beforeEach(() => {
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
windowState = installWindowStateBridge()
})
afterEach(() => {
vi.restoreAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('enforces the fps budget: frames inside the interval reschedule without drawing', () => {
const raf = installRaf()
const draw = vi.fn()
const loop = createBudgetedLoop(draw, { fps: 15 })
expect(raf.pending()).toBe(1)
raf.runNext(1000)
expect(draw).toHaveBeenCalledTimes(1)
raf.runNext(1010) // inside the ~66.7ms budget
expect(draw).toHaveBeenCalledTimes(1)
expect(raf.pending()).toBe(1) // loop stays alive
raf.runNext(1080) // past the budget
expect(draw).toHaveBeenCalledTimes(2)
loop.dispose()
})
it('pauses while the window is not observable and resumes when it is', () => {
const raf = installRaf()
const draw = vi.fn()
const loop = createBudgetedLoop(draw)
expect(raf.pending()).toBe(1)
window.dispatchEvent(new Event('blur'))
expect(raf.pending()).toBe(0)
window.dispatchEvent(new Event('focus'))
expect(raf.pending()).toBe(1)
windowState.emit({ isMinimized: true, isVisible: false })
expect(raf.pending()).toBe(0)
windowState.emit({ isMinimized: false, isVisible: true })
expect(raf.pending()).toBe(1)
loop.dispose()
})
it('parks when idleWhen reports nothing to animate and wakes on demand', () => {
const raf = installRaf()
const draw = vi.fn()
let idle = false
const loop = createBudgetedLoop(draw, { fps: 15, idleWhen: () => idle })
raf.runNext(1000)
expect(raf.pending()).toBe(1) // busy -> keeps running
idle = true
raf.runNext(1100)
expect(raf.pending()).toBe(0) // parked, zero pending work
expect(loop.isDormant()).toBe(true)
loop.wake() // still idle — but wake schedules; next tick re-parks
expect(raf.pending()).toBe(1)
raf.runNext(1200)
expect(raf.pending()).toBe(0)
idle = false
loop.wake()
expect(raf.pending()).toBe(1)
raf.runNext(1300)
expect(loop.isDormant()).toBe(false)
expect(raf.pending()).toBe(1)
loop.dispose()
})
it('a parked loop does not resume from focus/visibility churn alone', () => {
const raf = installRaf()
let idle = true
const loop = createBudgetedLoop(vi.fn(), { idleWhen: () => idle })
raf.runNext(1000)
expect(raf.pending()).toBe(0)
expect(loop.isDormant()).toBe(true)
window.dispatchEvent(new Event('blur'))
window.dispatchEvent(new Event('focus'))
expect(raf.pending()).toBe(0) // dormancy survives observability churn
loop.dispose()
})
it('dispose cancels the pending frame and makes wake a no-op', () => {
const raf = installRaf()
const draw = vi.fn()
const loop = createBudgetedLoop(draw)
expect(raf.pending()).toBe(1)
loop.dispose()
expect(raf.pending()).toBe(0)
loop.wake()
window.dispatchEvent(new Event('focus'))
expect(raf.pending()).toBe(0)
loop.dispose() // idempotent
})
})
+140
View File
@@ -0,0 +1,140 @@
/**
* Budgeted renderer loop — the one way to run a decorative rAF animation.
*
* Desktop has repeatedly shipped the same bug class: an animation loop that
* never sleeps (bots face clock #88543, pixel egg #88406, diffusion
* placeholder #88564, status pulse / spinner in the #77651 wave). Each fix
* re-implemented the same four behaviors by hand. This helper owns them:
*
* - **fps budget** — paints are skipped until `1000 / fps` elapsed; the loop
* still rides rAF so Chromium can align and pause it natively.
* - **observability pause** — wired to `createRendererLoopPauseController`:
* the loop cancels while the window is hidden, minimized, or unfocused and
* resumes when observable.
* - **idle dormancy** — when `idleWhen()` returns true after a draw, the loop
* parks (zero pending frames, zero timers) until `wake()` is called. This
* is the piece every hand-rolled loop forgot: "nothing visible to animate"
* must cost nothing.
* - **teardown** — `dispose()` cancels the frame, disposes the pause
* controller, and makes every later `wake()` a no-op. Callers MUST call it
* from their effect cleanup / plugin disposer.
*
* Usage:
*
* ```ts
* const loop = createBudgetedLoop(now => paint(now), {
* fps: 15,
* idleWhen: () => visibleThings.size === 0
* })
* // something became animatable again:
* loop.wake()
* // effect cleanup / plugin disable:
* loop.dispose()
* ```
*/
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
export interface BudgetedLoopOptions {
/** Paint budget in frames per second. Default 15 — visually smooth for
* decorative/avatar-scale animation at a quarter of display cadence. */
fps?: number
/** Return true when there is nothing worth animating (no visible targets,
* animation finished). Checked after every draw; when true the loop parks
* until `wake()`. Omit for loops that should run whenever observable. */
idleWhen?: () => boolean
/** Forwarded to the pause controller. Default true (pause on blur). */
pauseWhenUnfocused?: boolean
}
export interface BudgetedLoop {
/** Cancel everything and detach listeners. Idempotent; wake() after this is
* a no-op. Call from effect cleanup or the plugin disposer. */
dispose: () => void
/** True while parked by `idleWhen` (diagnostics/tests). */
isDormant: () => boolean
/** Resume a loop parked by `idleWhen` (e.g. a target mounted or scrolled
* into view). Safe to call redundantly; no-op while running or disposed. */
wake: () => void
}
export function createBudgetedLoop(
draw: (now: number) => void,
{ fps = 15, idleWhen, pauseWhenUnfocused = true }: BudgetedLoopOptions = {}
): BudgetedLoop {
const frameInterval = 1000 / fps
let frame = 0
let lastDraw = -Infinity
let dormant = false
let disposed = false
const cancelFrame = () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
frame = 0
}
}
const scheduleFrame = () => {
if (disposed || dormant || pauseController.isPaused() || frame !== 0) {
return
}
frame = window.requestAnimationFrame(tick)
}
const tick = (now: number) => {
frame = 0
if (disposed || pauseController.isPaused()) {
return
}
if (now - lastDraw >= frameInterval) {
draw(now)
lastDraw = now
}
// Idle dormancy: nothing to animate -> park with zero pending work.
// The owner wakes us when a target (re)appears.
if (idleWhen?.()) {
dormant = true
return
}
scheduleFrame()
}
const handlePauseChange = () => {
cancelFrame()
scheduleFrame()
}
const pauseController = createRendererLoopPauseController(handlePauseChange, { pauseWhenUnfocused })
const wake = () => {
if (disposed || !dormant) {
return
}
dormant = false
scheduleFrame()
}
scheduleFrame()
return {
dispose: () => {
if (disposed) {
return
}
disposed = true
cancelFrame()
pauseController.dispose()
},
isDormant: () => dormant,
wake
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
/**
* CHAT EMPTY STATE — the blank transcript as a contribution area.
*
* Core owns exactly one empty state: the intro splash, which belongs to a
* fresh draft with no session selected. A session that exists but has nothing
* in it yet falls outside that — and whoever opened it is the only one who
* knows what should stand in the gap. A bot's chat wants the bot's face and
* name; core has neither, and should not learn them.
*
* So the slot is contributed. A plugin claims the sessions it owns by
* returning an element for them and `null` for everything else, which is also
* how it stands down while the transcript is still hydrating.
*
* Ownership is per session, so EVERY registration is mounted and each answers
* for itself. Declining is free; two plugins claiming one session render both,
* which is the visible cost of never letting registration order silently
* suppress the plugin that actually owns the chat.
*/
export const CHAT_EMPTY_AREA = 'chat.empty'
/** Props handed to a chat-empty contribution's `render`. */
export interface ChatEmptyProps {
/** The live session whose transcript is empty. */
sessionId: string
}
/** Payload of a `chat.empty` contribution's `data`. */
export interface ChatEmptyContribution {
/** Renders the empty state, or returns `null` to decline the session and
* leave the transcript blank. Mounted as a component inside the
* contribution error boundary, so it can subscribe to its own stores and
* appear once they load — the roster a bot chat needs arrives after the
* transcript does. A throw degrades to an inline error, not a dead chat. */
render: (props: ChatEmptyProps) => ReactNode
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,327 @@
import { skillInvocationText } from '@hermes/shared'
import { extractImageRefs } from '@/lib/embedded-images'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import type { MessageReaction, SessionMessage } from '@/types/hermes'
import { assistantTextPart, chatMessageText, dedupeRepeatedTextInParts, reasoningPart, textPart } from './parts'
import {
applyStoredToolResult,
applyStoredToolResultToParts,
storedToolMessagePart,
textFromUnknown,
toolPartFromStoredCall,
withUniqueToolCallIds
} from './tool-parts'
import type { ChatMessage, ChatMessagePart } from './types'
const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
function displayContentForMessage(role: SessionMessage['role'], content: unknown): string {
const textContent = textFromUnknown(content)
if (role !== 'user') {
return textContent
}
// A `/skill` turn is stored expanded (the whole skill body). Current
// gateways project it to the invocation before it ever reaches us; this is
// the fallback for an older backend that still ships the raw payload.
const invocation = skillInvocationText(textContent)
if (invocation) {
return invocation
}
const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE)
if (!marker || marker.index === undefined) {
return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
}
const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim()
const attachedContext = textContent.slice(marker.index + marker[0].length)
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
// The prose keeps the `@file:` token the user typed, so it already chips in
// place. Only hoist a ref the prose is missing — a turn persisted by an older
// backend that stripped the tokens. Re-listing an inline ref would chip twice.
const missing = refs.filter(ref => !visibleText.includes(ref))
return [missing.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
}
function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null {
return displayKind === 'hidden' ? null : content
}
// A remote backend older than this app serves display_metadata as raw JSON text,
// and `in` throws on a primitive — which used to fail the whole session resume.
function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record<string, unknown> {
let parsed: unknown = metadata
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
return null
}
}
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
}
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
const count = parseDisplayMetadata(metadata)?.task_count
return typeof count === 'number' ? count : undefined
}
function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] {
const reactions = parseDisplayMetadata(metadata)?.reactions
if (!Array.isArray(reactions)) {
return []
}
return reactions.filter(
(r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string'
)
}
function timelineDisplayContent(message: SessionMessage, content: string): string {
if (message.display_kind === 'model_switch') {
return 'model changed'
}
if (message.display_kind === 'auto_continue') {
return 'resumed interrupted turn'
}
if (message.display_kind === 'personality_switch') {
return 'personality changed'
}
if (message.display_kind === 'async_delegation_complete') {
const count = timelineTaskCount(message.display_metadata)
return count === undefined
? 'background agent work finished'
: `${count} background agent${count === 1 ? '' : 's'} finished`
}
return content
}
export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let pendingToolParts: ChatMessagePart[] = []
let pendingToolTimestamp: number | undefined
let activeAssistantIndex: null | number = null
const clearPendingTools = () => {
pendingToolParts = []
pendingToolTimestamp = undefined
}
const earliestTimestamp = (...values: (number | undefined)[]) => {
const timestamps = values.filter((value): value is number => value !== undefined)
return timestamps.length ? Math.min(...timestamps) : undefined
}
const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => {
if (activeAssistantIndex === null) {
return false
}
const active = result[activeAssistantIndex]
if (!active || active.role !== 'assistant') {
activeAssistantIndex = null
return false
}
active.parts = [...active.parts, ...parts]
active.timestamp = earliestTimestamp(active.timestamp, timestamp, ...parts.map(part => part.timestamp))
return true
}
const flushPendingTools = (index: number) => {
if (!pendingToolParts.length) {
return
}
if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) {
result.push({
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
role: 'assistant',
parts: pendingToolParts,
timestamp: pendingToolTimestamp
})
activeAssistantIndex = result.length - 1
}
clearPendingTools()
}
messages.forEach((message, index) => {
if (message.role === 'tool') {
const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message)
if (updatedPendingToolParts) {
pendingToolParts = updatedPendingToolParts
return
}
if (applyStoredToolResult(result, message)) {
return
}
pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)]
pendingToolTimestamp ??= message.timestamp
return
}
const content =
message.display_content !== undefined
? message.display_content
: message.content || message.text || message.context || message.name
const rawDisplayContent = transcriptContent(
message.display_kind,
timelineDisplayContent(message, displayContentForMessage(message.role, content))
)
const displayRole =
message.display_kind === 'model_switch' ||
message.display_kind === 'async_delegation_complete' ||
message.display_kind === 'auto_continue' ||
message.display_kind === 'personality_switch'
? 'system'
: message.role
// Persisted user turns carry `@image:<path>` directive lines inline in
// the text (see tui_gateway/server.py's persist-time rewrite). The
// read-only bubble clamps its body to ~2 lines, and a large inline image
// thumbnail pushes any caption text below the clamp's visible area — so
// pull image refs out into `attachmentRefs` (same shape the local
// optimistic composer already uses) and render them via the dedicated
// attachments row below the bubble instead.
const imageRefExtraction = displayRole === 'user' && rawDisplayContent ? extractImageRefs(rawDisplayContent) : null
const displayContent = imageRefExtraction ? imageRefExtraction.cleanedText : rawDisplayContent
const extractedAttachmentRefs = imageRefExtraction?.refs.length ? imageRefExtraction.refs : undefined
const parts: ChatMessagePart[] = []
const reasoning =
message.reasoning ||
message.reasoning_content ||
(typeof message.reasoning_details === 'string' ? message.reasoning_details : '')
if (reasoning && message.role === 'assistant') {
parts.push(reasoningPart(reasoning, message.timestamp))
}
if (displayContent) {
parts.push(
displayRole === 'assistant'
? assistantTextPart(displayContent, message.timestamp)
: textPart(displayContent, message.timestamp)
)
}
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
parts.push(
...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex, message.timestamp))
)
}
if (!parts.length && !extractedAttachmentRefs?.length) {
if (message.role !== 'assistant') {
flushPendingTools(index)
activeAssistantIndex = null
}
return
}
const isToolOnlyAssistant =
message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call')
if (isToolOnlyAssistant) {
pendingToolParts = [...pendingToolParts, ...parts]
pendingToolTimestamp ??= message.timestamp
return
}
if (message.role === 'assistant') {
if (pendingToolParts.length) {
if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) {
parts.unshift(...pendingToolParts)
}
clearPendingTools()
}
const activeAssistant =
activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant'
? result[activeAssistantIndex]
: null
const currentHasToolCall = parts.some(part => part.type === 'tool-call')
const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call'))
if (activeAssistant && (currentHasToolCall || activeHasToolCall)) {
activeAssistant.parts = [...activeAssistant.parts, ...parts]
activeAssistant.timestamp = earliestTimestamp(
activeAssistant.timestamp,
message.timestamp,
...parts.map(part => part.timestamp)
)
return
}
} else {
flushPendingTools(index)
}
const reactions = messageReactions(message.display_metadata)
// Gateway resume names the durable row id `row_id`; the REST transcript
// prefetch ships the same messages.id as a numeric `id`. Either one lets
// reactions address this exact row later.
const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined)
result.push({
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
role: displayRole,
parts,
timestamp: earliestTimestamp(message.timestamp, ...parts.map(part => part.timestamp)),
...(rowId !== undefined ? { rowId } : {}),
...(reactions.length ? { reactions } : {}),
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
})
activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
})
flushPendingTools(messages.length)
const withoutGeneratedImageEchoes = result.map(message =>
message.role === 'assistant'
? { ...message, parts: dedupeRepeatedTextInParts(dedupeGeneratedImageEchoesInParts(message.parts)) }
: message
)
return withUniqueToolCallIds(
withoutGeneratedImageEchoes.filter(
m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text') || m.attachmentRefs?.length
)
)
}
@@ -0,0 +1,26 @@
export { toChatMessages } from './hydration'
export {
appendAssistantTextPart,
appendReasoningPart,
assistantTextPart,
chatMessageText,
collectUnspokenTurnSpeech,
completeOpenTimelineParts,
dedupeRepeatedTextInParts,
mergeFinalAssistantText,
reasoningPart,
renderMediaTags,
textPart
} from './parts'
export type { UnspokenTurnSpeech } from './parts'
export { branchGroupForUser, preserveLocalAssistantErrors } from './reconciliation'
export {
restorePendingClarifyToolCall,
sealOpenToolParts,
settlePendingClarifyToolCall,
stripPendingClarifyProjectionForCache,
upsertToolPart,
withUniqueToolCallIdsWithinMessage
} from './tool-parts'
export type { PendingClarifyProjection, SettledClarifyProjection } from './tool-parts'
export type { ChatMessage, ChatMessagePart, GatewayEventPayload, TimelinePartMetadata } from './types'
+290
View File
@@ -0,0 +1,290 @@
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import type { ChatMessage, ChatMessagePart } from './types'
export function textPart(text: string, timestamp?: number): ChatMessagePart {
return { type: 'text', text, ...(timestamp !== undefined ? { timestamp } : {}) }
}
export function reasoningPart(text: string, timestamp?: number): ChatMessagePart {
return { type: 'reasoning', text, ...(timestamp !== undefined ? { timestamp } : {}) }
}
const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
function unquoteMediaPath(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed
}
function mediaLink(value: string): string {
const path = unquoteMediaPath(value)
return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})`
}
export function renderMediaTags(text: string): string {
return text
.replace(
MEDIA_LINE_RE,
(_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}`
)
.replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value))
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
}
export function assistantTextPart(text: string, timestamp?: number): ChatMessagePart {
return textPart(renderMediaTags(text), timestamp)
}
export function chatMessageText(message: ChatMessage): string {
return message.parts
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
.map(part => part.text)
.join('')
}
export interface UnspokenTurnSpeech {
/** First unspoken assistant bubble — stable for the turn, the live speech session binds to it. */
id: string
/** Whether the newest assistant bubble is still streaming. */
pending: boolean
/** All unspoken assistant text in message order, bubbles joined on a blank line. */
text: string
}
/**
* Collect every unspoken assistant bubble after `lastSpokenId`, in order.
*
* A turn with tool calls produces several assistant bubbles — narration
* ("Let me check…") sealed as interims, then the final answer as a fresh
* bubble. Voice conversation speaks a turn through ONE live session bound to
* one response id, so it needs all of that text as a single growing string;
* selecting only one bubble silently drops everything after it. The blank-line
* join is a sentence boundary for the server's cutter, so a sealed bubble's
* tail is flushed as soon as the next bubble starts.
*/
export function collectUnspokenTurnSpeech(
messages: ChatMessage[],
lastSpokenId: string | null
): UnspokenTurnSpeech | null {
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
let id: string | null = null
let pending = false
const parts: string[] = []
for (const message of messages.slice(spokenIndex + 1)) {
if (message.role !== 'assistant' || message.hidden) {
continue
}
pending = Boolean(message.pending)
const text = chatMessageText(message).trim()
if (!text) {
continue
}
id ??= message.id
parts.push(text)
}
if (!id) {
return null
}
return { id, pending, text: parts.join('\n\n') }
}
const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
/**
* Drop earlier text parts that a later text part repeats verbatim (after
* whitespace normalization). Providers that continue a turn after a tool
* call sometimes re-send the previous assistant text as the next message's
* prefix (tool_calls row, then a stop row with identical prose) — the turn
* merge then holds the same paragraph twice and everything in it renders
* twice, most visibly ::preview frames. The LAST occurrence is the
* authoritative one; keep it.
*/
export function dedupeRepeatedTextInParts(parts: ChatMessagePart[]): ChatMessagePart[] {
const lastByText = new Map<string, number>()
parts.forEach((part, index) => {
if (part.type === 'text') {
const key = normalizeWs(part.text)
if (key) {
lastByText.set(key, index)
}
}
})
const dropped = parts.filter((part, index) => {
if (part.type !== 'text') {
return true
}
const key = normalizeWs(part.text)
return !key || lastByText.get(key) === index
})
return dropped.length === parts.length ? parts : dropped
}
/**
* Merge the final assistant text into a message's parts.
*
* - Removes all existing `text` parts (they were streamed deltas, now superseded
* by the authoritative final response).
* - Keeps `reasoning` parts, but drops one that the final text fully covers
* (reasoning ⊆ final) — the final restates it. A short final ("Done.") must
* NOT swallow a longer reasoning block that merely starts with it (#61447).
* - Keeps all other part types (tool-call, image, etc.).
* - Appends the final text as a new text part.
*/
export function mergeFinalAssistantText(
parts: ChatMessagePart[],
finalText: string,
fallbackTimestamp?: number
): ChatMessagePart[] {
// Empty / whitespace-only completion is not authoritative — keep streamed
// text, reasoning, and tool parts (#95514).
if (!finalText.trim()) {
return parts
}
const dedupeReference = normalizeWs(finalText)
const streamedText = normalizeWs(
parts
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
.map(part => part.text)
.join('')
)
// An authoritative final that is exactly the concatenation of streamed text
// confirms the content without erasing text↔reasoning activity boundaries.
if (streamedText && streamedText === dedupeReference) {
return parts
}
const previousText = parts.findLast(part => part.type === 'text')
const kept = parts.filter(part => {
if (part.type === 'text') {
// Sealed text parts were already finalized into their own bubbles —
// this filter only runs on the LAST streaming bubble, so there are no
// sealed parts here. All text parts are streamed deltas that get
// replaced by the authoritative final text.
return false
}
if (part.type !== 'reasoning' || !dedupeReference) {
return true
}
// Reasoning is a restatement only when the final FULLY covers it.
// The reverse direction is not considered — a short final must not
// swallow a longer reasoning block (#61447).
const r = normalizeWs(part.text)
return !(r && dedupeReference.startsWith(r))
})
if (!finalText) {
return kept
}
const finalPart = assistantTextPart(finalText, previousText?.timestamp ?? fallbackTimestamp)
if (previousText?.completedAt !== undefined) {
finalPart.completedAt = previousText.completedAt
}
return [...kept, finalPart]
}
/** Seal every still-open visible activity when the assistant turn stops. */
export function completeOpenTimelineParts(parts: ChatMessagePart[], completedAt: number): ChatMessagePart[] {
return parts.map(part =>
part.timestamp !== undefined && part.completedAt === undefined
? ({ ...part, completedAt } as ChatMessagePart)
: part
)
}
// Coalesce only adjacent deltas of the same channel. Switching between text
// and reasoning is a real timeline boundary and must remain visible even when
// both channels arrive inside one batched renderer flush.
function appendStreamPart(
parts: ChatMessagePart[],
type: 'reasoning' | 'text',
delta: string,
timestamp?: number
): { index: number; parts: ChatMessagePart[] } {
const next = [...parts]
const tailIndex = next.length - 1
const tail = next[tailIndex]
if (tail?.type === type && tail.completedAt === undefined) {
next[tailIndex] = { ...tail, text: `${tail.text}${delta}` } as ChatMessagePart
return { index: tailIndex, parts: next }
}
if (
timestamp !== undefined &&
(tail?.type === 'text' || tail?.type === 'reasoning') &&
tail.completedAt === undefined
) {
next[tailIndex] = { ...tail, completedAt: timestamp } as ChatMessagePart
}
const STREAM_PART: Record<'reasoning' | 'text', (text: string, timestamp?: number) => ChatMessagePart> = {
reasoning: reasoningPart,
text: textPart
}
next.push(STREAM_PART[type](delta, timestamp))
return { index: next.length - 1, parts: next }
}
export function appendReasoningPart(parts: ChatMessagePart[], delta: string, timestamp?: number): ChatMessagePart[] {
return appendStreamPart(parts, 'reasoning', delta, timestamp).parts
}
export function appendAssistantTextPart(
parts: ChatMessagePart[],
delta: string,
timestamp?: number
): ChatMessagePart[] {
const { index, parts: next } = appendStreamPart(parts, 'text', delta, timestamp)
const part = next[index]
if (part?.type !== 'text') {
return next
}
const mayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (mayContainMedia || part.text.includes('MEDIA:')) {
const rendered = renderMediaTags(part.text)
if (rendered !== part.text) {
next[index] = { ...part, text: rendered }
}
}
return next
}
@@ -0,0 +1,195 @@
import { chatMessageText } from './parts'
import type { ChatMessage, ChatMessagePart } from './types'
const validTimelineBoundary = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0
const earliestBoundary = (...values: (number | undefined)[]) => {
const valid = values.filter(validTimelineBoundary)
return valid.length ? Math.min(...valid) : undefined
}
const latestBoundary = (...values: (number | undefined)[]) => {
const valid = values.filter(validTimelineBoundary)
return valid.length ? Math.max(...valid) : undefined
}
const normalizedTimelineText = (message: ChatMessage) => chatMessageText(message).replace(/\s+/g, ' ').trim()
const assistantTimelineMatch = (stored: ChatMessage, local: ChatMessage) => {
if (stored.id === local.id) {
return true
}
const localToolIds = new Set(
local.parts
.filter(part => part.type === 'tool-call')
.map(part => (part.type === 'tool-call' ? part.toolCallId : ''))
)
const toolMatch = stored.parts.some(part => part.type === 'tool-call' && localToolIds.has(part.toolCallId))
if (toolMatch) {
return true
}
const storedText = normalizedTimelineText(stored)
return Boolean(storedText) && storedText === normalizedTimelineText(local)
}
const timelinePartMatch = (stored: ChatMessagePart, local: ChatMessagePart) => {
if (stored.type !== local.type) {
return false
}
if (stored.type === 'tool-call' && local.type === 'tool-call') {
return stored.toolCallId === local.toolCallId
}
if ((stored.type === 'text' || stored.type === 'reasoning') && local.type === stored.type) {
return stored.text.replace(/\s+/g, ' ').trim() === local.text.replace(/\s+/g, ' ').trim()
}
return false
}
/** Keep richer live timing when durable hydration has only one timestamp per row. */
function reconcileLocalAssistantTimeline(nextMessages: ChatMessage[], currentMessages: ChatMessage[]): ChatMessage[] {
const localAssistants = currentMessages.filter(message => message.role === 'assistant' && !message.hidden)
const matches = new Map<number, ChatMessage>()
let localCursor = localAssistants.length - 1
for (let nextIndex = nextMessages.length - 1; nextIndex >= 0; nextIndex -= 1) {
const message = nextMessages[nextIndex]
if (message.role !== 'assistant' || message.hidden) {
continue
}
for (let localIndex = localCursor; localIndex >= 0; localIndex -= 1) {
const local = localAssistants[localIndex]
if (assistantTimelineMatch(message, local)) {
matches.set(nextIndex, local)
localCursor = localIndex - 1
break
}
}
}
return nextMessages.map((message, messageIndex) => {
const local = matches.get(messageIndex)
if (!local) {
return message
}
const unusedLocalParts = new Set(local.parts.map((_, index) => index))
const parts = message.parts.map(part => {
const localIndex = local.parts.findIndex(
(candidate, index) => unusedLocalParts.has(index) && timelinePartMatch(part, candidate)
)
if (localIndex === -1) {
return part
}
unusedLocalParts.delete(localIndex)
const localPart = local.parts[localIndex]
return {
...part,
completedAt: latestBoundary(part.completedAt, localPart.completedAt),
timestamp: earliestBoundary(part.timestamp, localPart.timestamp)
} as ChatMessagePart
})
return {
...message,
completedAt: latestBoundary(message.completedAt, local.completedAt, ...parts.map(part => part.completedAt)),
parts,
timestamp: earliestBoundary(message.timestamp, local.timestamp, ...parts.map(part => part.timestamp))
}
})
}
export function preserveLocalAssistantErrors(
nextMessages: ChatMessage[],
currentMessages: ChatMessage[]
): ChatMessage[] {
nextMessages = reconcileLocalAssistantTimeline(nextMessages, currentMessages)
const localById = new Map(currentMessages.map(message => [message.id, message]))
const mergedNextMessages = nextMessages.map(message => {
if (message.role !== 'assistant' || message.error || message.hidden) {
return message
}
const local = localById.get(message.id)
if (!local || local.role !== 'assistant' || !local.error || local.hidden) {
return message
}
return {
...message,
error: local.error,
pending: false
}
})
const existingIds = new Set(mergedNextMessages.map(message => message.id))
const preserveIds = new Set<string>()
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const tailUserInNext = [...mergedNextMessages].reverse().find(message => message.role === 'user' && !message.hidden)
const tailUserText = tailUserInNext ? normalize(chatMessageText(tailUserInNext)) : ''
const tailUserRefs = tailUserInNext ? (tailUserInNext.attachmentRefs ?? []).join('\n') : ''
const matchesTailUserInNext = (candidate: ChatMessage) =>
Boolean(tailUserInNext) &&
normalize(chatMessageText(candidate)) === tailUserText &&
(candidate.attachmentRefs ?? []).join('\n') === tailUserRefs
for (let index = 0; index < currentMessages.length; index += 1) {
const message = currentMessages[index]
if (message.role !== 'assistant' || !message.error || message.hidden || existingIds.has(message.id)) {
continue
}
preserveIds.add(message.id)
for (let probe = index - 1; probe >= 0; probe -= 1) {
const candidate = currentMessages[probe]
if (candidate.hidden) {
continue
}
if (candidate.role === 'user' && !existingIds.has(candidate.id) && !matchesTailUserInNext(candidate)) {
preserveIds.add(candidate.id)
}
break
}
}
if (preserveIds.size === 0) {
return mergedNextMessages
}
const preserved = currentMessages
.filter(message => preserveIds.has(message.id))
.map(message => ({ ...message, pending: false }))
return [...mergedNextMessages, ...preserved]
}
export function branchGroupForUser(userMessage: ChatMessage): string {
return `branch:${userMessage.id}`
}
@@ -0,0 +1,844 @@
import { firstStringField, normalize } from '@/lib/text'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart, GatewayEventPayload } from './types'
function toolId(payload: GatewayEventPayload | undefined): string {
return payload?.tool_id || payload?.tool_call_id || payload?.id || ''
}
let liveToolCounter = 0
function nextLiveToolId(name: string): string {
liveToolCounter += 1
return `live-tool:${name}:${liveToolCounter}`
}
function normalizeToolMatchValue(value: string): string {
return normalize(value)
}
function collectToolMatchValues(query: string, context: string, preview: string): string[] {
return [...new Set([query, context, preview].map(normalizeToolMatchValue).filter(Boolean))]
}
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
}
function parseMaybeJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string' || !value.trim()) {
return {}
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
} catch {
return {}
}
}
function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
for (const value of values) {
const parsed = parseMaybeJsonObject(value)
if (Object.keys(parsed).length > 0) {
return parsed
}
}
return {}
}
function liveToolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
const direct = firstNonEmptyObject(payload?.args, payload?.arguments)
const input = firstNonEmptyObject(payload?.input)
const fn = recordFromUnknown(input.function)
const nested = firstNonEmptyObject(
input.args,
input.arguments,
input.parameters,
input.input,
fn?.arguments,
fn?.args,
fn?.parameters
)
return {
...input,
...nested,
...direct
}
}
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
const payloadArgs = liveToolArgs(payload)
// `question` is clarify's identifying arg: a synthetic row hydrated from
// `clarify.request` (a fresh request id) must correlate with the `tool.start`
// row (the model's tool_call_id) so the two ids don't produce a duplicate
// clarify card — same correlation ClarifyToolPending uses for request↔args.
// `server` is setup_mcp's identifying arg, for the identical reason.
const query =
firstStringField(payloadArgs, ['search_term', 'query', 'question', 'server', 'command', 'code', 'path']) ||
batchClarifyMatchValue(payloadArgs.questions)
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
return collectToolMatchValues(query, context, preview)
}
/**
* The batch-clarify counterpart of the `question` correlation key: a batch
* payload has no top-level `question`, only `questions[]`, so without this
* the request row and the tool.start row never match and the card mounts
* twice. The joined per-question texts identify the batch the same way one
* question text identifies a single prompt. The `\u0000` separator cannot
* appear in real question text, so a batch key can never collide with a
* single-question key.
*/
function batchClarifyMatchValue(questions: unknown): string {
if (!Array.isArray(questions)) {
return ''
}
const texts = questions
.map(entry => {
if (!entry || typeof entry !== 'object') {
return ''
}
const question = (entry as Record<string, unknown>).question
return typeof question === 'string' ? question.trim() : ''
})
.filter(Boolean)
return texts.length > 0 ? texts.join('\u0000') : ''
}
function toolPartMatchValues(part: ChatMessagePart): string[] {
if (part.type !== 'tool-call' || !part.args || typeof part.args !== 'object') {
return []
}
const args = part.args as Record<string, unknown>
const query =
firstStringField(args, ['search_term', 'query', 'question', 'server', 'command', 'code', 'path']) ||
batchClarifyMatchValue(args.questions)
const context = typeof args.context === 'string' ? args.context.trim() : ''
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
return collectToolMatchValues(query, context, preview)
}
function hasToolMatchOverlap(left: string[], right: string[]): boolean {
if (!left.length || !right.length) {
return false
}
const rightSet = new Set(right)
return left.some(value => rightSet.has(value))
}
function findToolPartIndex(
parts: ChatMessagePart[],
name: string,
stableId: string,
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete'
): number {
const matchValues = toolPayloadMatchValues(payload)
const overlaps = (index: number) => hasToolMatchOverlap(matchValues, toolPartMatchValues(parts[index]))
if (stableId) {
const stableIndex = parts.findIndex(part => part.type === 'tool-call' && part.toolCallId === stableId)
if (stableIndex >= 0) {
return stableIndex
}
// Some live streams start without an id, then complete with one. Fall
// through to pending same-name/context matching so the completion updates
// the synthetic live row instead of appending a duplicate completed row.
if (phase === 'running' && !matchValues.length) {
return -1
}
}
const pendingIndices = parts
.map((part, index) => ({ part, index }))
.filter(({ part }) => part.type === 'tool-call' && part.toolName === name && part.result === undefined)
.map(({ index }) => index)
if (pendingIndices.length === 0) {
return -1
}
if (matchValues.length) {
const contextualIndex = pendingIndices.find(overlaps)
if (contextualIndex !== undefined) {
return contextualIndex
}
}
if (pendingIndices.length === 1) {
const [singlePendingIndex] = pendingIndices
if (phase === 'running' && matchValues.length && !overlaps(singlePendingIndex)) {
return stableId ? singlePendingIndex : -1
}
return singlePendingIndex
}
// Completion events without stable IDs frequently arrive after multiple
// same-name starts (parallel tool calls). Resolve them oldest-first so we
// don't collapse an entire burst into a single row.
if (phase === 'complete') {
return pendingIndices[0]
}
if (stableId) {
return pendingIndices[0]
}
// For progress/running events with no stable id, update the most-recent
// pending same-name tool instead of creating a phantom extra row.
return pendingIndices.at(-1) ?? -1
}
// Carry todo state across sparse progress payloads: if this todo event lacks
// a `todos` field, fall back to whatever we previously stored on the part.
function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined {
if (payload && Object.hasOwn(payload, 'todos')) {
const next = parseTodos(payload.todos)
return next === null ? undefined : { todos: next }
}
if (payload?.name !== 'todo') {
return undefined
}
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {
return { todos: carried }
}
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
const prev = parseMaybeJsonObject(prevArgs)
const eventArgs = liveToolArgs(payload)
return {
...prev,
...eventArgs,
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
function toolResult(
payload: GatewayEventPayload | undefined,
prevResult?: unknown,
prevArgs?: unknown
): Record<string, unknown> {
const parsedResult = parseMaybeJsonObject(payload?.result)
return {
...parsedResult,
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
...carryTodos(payload, prevResult, prevArgs),
...(payload?.error ? { error: payload.error } : {})
}
}
function completeOpenStreamParts(parts: ChatMessagePart[], completedAt: number): ChatMessagePart[] {
return parts.map(part =>
(part.type === 'text' || part.type === 'reasoning') && part.completedAt === undefined
? ({ ...part, completedAt } as ChatMessagePart)
: part
)
}
export function upsertToolPart(
parts: ChatMessagePart[],
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete',
occurredAt = Date.now() / 1000
): ChatMessagePart[] {
const stableId = toolId(payload)
const name = payload?.name || 'tool'
// A completion can be the first tool event observed after reconnect, so it
// also constitutes a text/reasoning -> tool boundary when no start arrived.
const next = completeOpenStreamParts(parts, occurredAt)
const index = findToolPartIndex(next, name, stableId, payload, phase)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
const id =
stableId ||
(prev && 'toolCallId' in prev && typeof prev.toolCallId === 'string' ? prev.toolCallId : '') ||
nextLiveToolId(name)
const base = {
type: 'tool-call' as const,
toolCallId: id,
toolName: name,
args: args as never,
argsText: JSON.stringify(args),
timestamp: prev?.timestamp ?? occurredAt,
...(phase === 'complete' && {
completedAt: occurredAt,
result: toolResult(payload, prevResult, prevArgs),
isError: Boolean(payload?.error)
})
} satisfies ChatMessagePart
if (index === -1) {
return [...next, base]
}
next[index] = { ...next[index], ...base }
return next
}
export interface PendingClarifyProjection {
messages: ChatMessage[]
streamId: string
}
export interface SettledClarifyProjection {
messages: ChatMessage[]
streamId: string | null
}
interface PendingClarifyLocation {
messageIndex: number
partIndex: number
}
function findPendingClarifyLocation(
messages: ChatMessage[],
payload: GatewayEventPayload
): PendingClarifyLocation | null {
const stableId = toolId(payload)
const matchValues = toolPayloadMatchValues(payload)
let solePending: PendingClarifyLocation | null = null
let pendingCount = 0
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex]
for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = message.parts[partIndex]
if (part.type !== 'tool-call' || part.toolName !== 'clarify' || part.result !== undefined) {
continue
}
pendingCount += 1
solePending = { messageIndex, partIndex }
const exactId = Boolean(stableId && part.toolCallId === stableId)
const contextual = hasToolMatchOverlap(matchValues, toolPartMatchValues(part))
if (exactId || contextual) {
return { messageIndex, partIndex }
}
}
}
// Older/sparse projections can lose the identifying args. One session can
// only block on one clarify at a time, so a sole open clarify is still the
// authoritative row even without a usable correlation value.
return pendingCount === 1 ? solePending : null
}
function skippedClarifyResult(part: Extract<ChatMessagePart, { type: 'tool-call' }>): Record<string, unknown> {
const args = recordFromUnknown(part.args) ?? {}
const questions = Array.isArray(args.questions) ? args.questions : []
if (questions.length > 0) {
return {
responses: questions.map(entry => ({
question: firstStringField(recordFromUnknown(entry) ?? {}, ['question']),
user_response: ''
})),
timed_out: true
}
}
return {
question: firstStringField(args, ['question']),
user_response: ''
}
}
/** Mark one pending clarify as timed out/settled without ending a later phase
* of the same assistant turn. `keepMessageRunning` keeps the containing message
* open for subsequent deltas while the clarify part itself becomes settled. */
export function settlePendingClarifyToolCall(
messages: ChatMessage[],
payload: GatewayEventPayload,
keepMessageRunning: boolean,
occurredAt = Date.now() / 1000
): SettledClarifyProjection {
const clarifyPayload = { ...payload, name: 'clarify' }
const location = findPendingClarifyLocation(messages, clarifyPayload)
if (!location) {
return { messages, streamId: null }
}
const message = messages[location.messageIndex]
const part = message.parts[location.partIndex]
if (part.type !== 'tool-call') {
return { messages, streamId: null }
}
const parts = [...message.parts]
parts[location.partIndex] = {
...part,
completedAt: occurredAt,
result: skippedClarifyResult(part)
}
const next = [...messages]
next[location.messageIndex] = { ...message, parts, pending: keepMessageRunning }
return { messages: next, streamId: message.id }
}
/** Remove ephemeral clarify liveness before writing the durable transcript-tail
* cache. A synthetic request-id part is dropped; a provider-authored call stays
* visible but loses its local `pending` bit until an authoritative resume
* snapshot re-arms it. */
export function stripPendingClarifyProjectionForCache(messages: ChatMessage[], requestId?: string): ChatMessage[] {
let changed = false
const next: ChatMessage[] = []
for (const message of messages) {
const hasOpenClarify = message.parts.some(
part => part.type === 'tool-call' && part.toolName === 'clarify' && part.result === undefined
)
if (!hasOpenClarify) {
next.push(message)
continue
}
const parts = message.parts.filter(
part =>
!(
requestId &&
part.type === 'tool-call' &&
part.toolName === 'clarify' &&
part.result === undefined &&
part.toolCallId === requestId
)
)
changed = true
if (parts.length > 0) {
next.push({ ...message, parts, pending: false })
}
}
return changed ? next : messages
}
/**
* Re-arm a clarify request against a transcript that may already have been
* hydrated from REST/session.resume.
*
* Provider transcript shapes differ: Codex commonly persists a tool-only
* assistant row, while DeepSeek can persist visible assistant text and the
* clarify call on the same row. The clarify request id is distinct from the
* provider's tool-call id and can arrive when `state.streamId` is null, so the
* generic live-stream mutator would append a second assistant row at the tail.
* Match the existing open clarify by its question(s), keep that row's provider
* tool id/position, and mark it running. If the backend projection omitted the
* tool call, attach it to trailing assistant commentary or seed one tail row as
* a last resort.
*/
export function restorePendingClarifyToolCall(
messages: ChatMessage[],
payload: GatewayEventPayload,
occurredAt = Date.now() / 1000
): PendingClarifyProjection {
const clarifyPayload = { ...payload, name: 'clarify' }
const location = findPendingClarifyLocation(messages, clarifyPayload)
if (location) {
const message = messages[location.messageIndex]
if (message.pending) {
return { messages, streamId: message.id }
}
const next = [...messages]
next[location.messageIndex] = { ...message, pending: true }
return { messages: next, streamId: message.id }
}
const parts = upsertToolPart([], clarifyPayload, 'running', occurredAt)
const tailIndex = messages.findLastIndex(message => !message.hidden)
const tail = messages[tailIndex]
if (tail?.role === 'assistant') {
const next = [...messages]
next[tailIndex] = {
...tail,
parts: [...completeOpenStreamParts(tail.parts, occurredAt), ...parts],
pending: true
}
return { messages: next, streamId: tail.id }
}
const streamId = nextLiveToolId('clarify-message')
return {
messages: [
...messages,
{
id: streamId,
role: 'assistant',
parts,
pending: true,
timestamp: occurredAt
}
],
streamId
}
}
/**
* Turn-settle reconciliation: close every tool-call part that never received
* its completion event. A `tool.complete` lost to a degraded websocket
* (reconnect, profile swap, hidden window) leaves the part without a `result`,
* which renders as a permanently spinning tool row even though the turn itself
* completed. A settled session cannot have tools still running, so an open
* part at settle time is a lost event, not live work. Pending messages are
* left alone, and no-op calls return the input array unchanged.
*/
export function sealOpenToolParts(messages: ChatMessage[]): ChatMessage[] {
let changed = false
const next = messages.map(message => {
if (message.role !== 'assistant' || message.pending) {
return message
}
let partChanged = false
const parts = message.parts.map(part => {
if (part.type !== 'tool-call' || Object.hasOwn(part, 'result')) {
return part
}
partChanged = true
return { ...part, result: {} }
})
if (!partChanged) {
return message
}
changed = true
return { ...message, parts }
})
return changed ? next : messages
}
// ── Stored-tool conversion (hydration path) ────────────────────────────────
export function textFromUnknown(value: unknown, depth = 0): string {
if (typeof value === 'string') {
return value
}
if (value === null || value === undefined) {
return ''
}
if (depth > 2) {
return ''
}
if (Array.isArray(value)) {
return value.map(item => textFromUnknown(item, depth + 1)).join('')
}
if (typeof value === 'object') {
const row = value as Record<string, unknown>
const textValue = row.text ?? row.output_text ?? row.content ?? row.message
const nestedText = textFromUnknown(textValue, depth + 1)
if (nestedText) {
return nestedText
}
try {
return JSON.stringify(value)
} catch {
return ''
}
}
return String(value)
}
function parseStoredToolResult(content: unknown): unknown {
if (content && typeof content === 'object') {
return content
}
const textContent = textFromUnknown(content)
if (!textContent.trim()) {
return ''
}
try {
return JSON.parse(textContent)
} catch {
return textContent
}
}
export function toolPartFromStoredCall(call: unknown, fallbackIndex: number, timestamp?: number): ChatMessagePart {
const row = recordFromUnknown(call) ?? {}
const fn = recordFromUnknown(row.function)
const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`)
const toolName = String(
row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool'
)
const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input)
return {
type: 'tool-call',
toolCallId: id,
toolName,
args: args as never,
argsText: Object.keys(args).length ? JSON.stringify(args) : '',
...(timestamp !== undefined ? { timestamp } : {})
}
}
export function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
const toolCallId = toolMessage.tool_call_id || undefined
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i]
if (message.role !== 'assistant') {
continue
}
const partIndex = message.parts.findIndex(
part =>
part.type === 'tool-call' &&
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
)
if (partIndex < 0) {
continue
}
const parts = [...message.parts]
const existing = parts[partIndex]
parts[partIndex] = {
...existing,
completedAt: toolMessage.timestamp,
result: parseStoredToolResult(content),
isError: false
} as ChatMessagePart
messages[i] = { ...message, parts }
return true
}
return false
}
export function applyStoredToolResultToParts(
parts: ChatMessagePart[],
toolMessage: SessionMessage
): ChatMessagePart[] | null {
const toolCallId = toolMessage.tool_call_id || undefined
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name
const partIndex = parts.findIndex(
part =>
part.type === 'tool-call' &&
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
)
if (partIndex < 0) {
return null
}
const next = [...parts]
const existing = next[partIndex]
next[partIndex] = {
...existing,
completedAt: toolMessage.timestamp,
result: parseStoredToolResult(content),
isError: false
} as ChatMessagePart
return next
}
export function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
const name = toolMessage.tool_name || toolMessage.name || 'tool'
const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '')
// Prefer the full arguments when the gateway projection carries them:
// `context` is an 80-char display preview, and the expanded tool row
// rebuilds the real command from args. Keep `context` alongside as the
// title-side placeholder.
const storedArgs = parseMaybeJsonObject(toolMessage.args)
const args = { ...storedArgs, ...(context ? { context } : {}) }
return {
type: 'tool-call',
toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`,
toolName: name,
args: args as never,
argsText: Object.keys(args).length ? JSON.stringify(args) : '',
timestamp: toolMessage.timestamp,
completedAt: toolMessage.timestamp,
result: context ? { context } : {},
isError: false
}
}
export function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] {
const seen = new Set<string>()
return messages.map(message => {
let changed = false
const parts = message.parts.map((part, index) => {
if (part.type !== 'tool-call') {
return part
}
const id = part.toolCallId || `${message.id}-tool-${index}`
if (!seen.has(id)) {
seen.add(id)
if (part.toolCallId) {
return part
}
changed = true
return { ...part, toolCallId: id } as ChatMessagePart
}
changed = true
const uniqueId = `${id}-${message.id}-${index}`
seen.add(uniqueId)
return { ...part, toolCallId: uniqueId } as ChatMessagePart
})
return changed ? { ...message, parts } : message
})
}
/**
* Ensure no two `tool-call` parts of a SINGLE message share a `toolCallId`.
*
* assistant-ui's `useResources` derives one resource key per content part
* (`toolCallId-<id>`) and throws `Duplicate key <key> in useResources` on a
* collision, which the desktop error boundary turns into a renderer crash loop
* that blanks the window (#87857). Two paths can produce a message whose parts
* carry the same id: the streaming reducer appends the same tool-call part
* twice under a specific optimistic-update ordering, and coalescing tool-only
* assistant turns can fold two parts with the same id into one message. Neither
* passes through {@link withUniqueToolCallIds} — that runs only on the static
* `toChatMessages` output, not the live runtime boundary — so the dedup is
* applied again at the point ChatMessages are converted for the runtime.
*
* The scope is deliberately per-message (a fresh seen-set each call): the
* assistant-ui key space is per-message, so a `toolCallId` shared across
* different messages is not a collision and must not be renamed. Only the
* later duplicate within one message is renamed, mirroring the list-level
* helper. Returns the same reference when nothing changes, so the runtime
* repository's identity cache is preserved for the common no-duplicate case.
*/
export function withUniqueToolCallIdsWithinMessage(message: ChatMessage): ChatMessage {
let seen: null | Set<string> = null
let changed = false
const parts = message.parts.map((part, index) => {
if (part.type !== 'tool-call' || !part.toolCallId) {
return part
}
if (seen === null) {
seen = new Set<string>()
}
if (!seen.has(part.toolCallId)) {
seen.add(part.toolCallId)
return part
}
changed = true
const uniqueId = `${part.toolCallId}-dup-${index}`
seen.add(uniqueId)
return { ...part, toolCallId: uniqueId } as ChatMessagePart
})
return changed ? { ...message, parts } : message
}
+182
View File
@@ -0,0 +1,182 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { type BillingBlock } from '@hermes/shared'
import type { ErrorSurface } from '@/lib/error-surface'
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
export interface TimelinePartMetadata {
/** Unix seconds when this visible activity segment began. Fractional values
* preserve the millisecond precision available on live gateway events. */
timestamp?: number
/** Unix seconds when this segment stopped or handed off to the next one. */
completedAt?: number
}
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number] & TimelinePartMetadata
export type ChatMessage = {
id: string
role: SessionMessage['role']
parts: ChatMessagePart[]
timestamp?: number
completedAt?: number
pending?: boolean
error?: string
/** Structured layer descriptor for a failed turn (parsed error_surface).
* Drives the error card's layer label + actions; absent on older
* backends, where the card falls back to generic copy. */
errorSurface?: ErrorSurface
branchGroupId?: string
hidden?: boolean
/** Sealed mid-turn commentary (`message.interim`) — rendered without the
* action footer so only the turn's final reply carries copy/refresh, and
* the live view matches rehydration (which merges the turn into one bubble). */
interim?: boolean
/** Whole-turn wall-clock seconds (message.start → message.complete),
* stamped by the desktop when it watched the turn run. Absent for
* messages hydrated from history — the backend doesn't persist it. */
durationS?: number
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
attachmentRefs?: string[]
/** Durable backend `messages.id`. Absent until the row is persisted. */
rowId?: number
/** Emoji reactions on this message — one per author (see MessageReaction). */
reactions?: MessageReaction[]
}
export type GatewayEventPayload = {
/** Unix seconds supplied by tests/newer gateways; the desktop falls back to
* its local receipt clock when older gateways omit it. */
timestamp?: number
text?: string
rendered?: string
status?: string
message?: string
id?: string
name?: string
tool_id?: string
tool_call_id?: string
args?: unknown
arguments?: unknown
context?: string
input?: unknown
preview?: string
result?: unknown
summary?: string
error?: string | boolean
// message.complete with status "error" — structured {layer, code, retryable}
// descriptor naming which stack layer failed (agent/error_surface.py).
// Absent on older gateways; consumers must fall back to string heuristics.
error_surface?: unknown
inline_diff?: string
duration_s?: number
todos?: unknown
revision?: number
model?: string
provider?: string
reasoning_effort?: string
service_tier?: string
fast?: boolean
approval_mode?: string
yolo?: boolean
running?: boolean
turn_started_at?: number | null
cwd?: string
branch?: string
terminal_backend?: string
credential_warning?: string
install_warning?: string
personality?: string
usage?: Partial<UsageStats>
// agent.terminal.output — live chunk for a read-only agent terminal tab
process_id?: string
chunk?: string
// clarify.request
request_id?: string
question?: string
// btw.complete / background.complete — id of the side/background task
task_id?: string
choices?: string[] | null
multi_select?: boolean
// clarify.request batch form: questions replaces question/choices, and
// answers (qid → locked answer) rides along on reconnect replay only.
questions?: unknown
answers?: Record<string, unknown>
// mcp.setup.request (setup_mcp tool — inline MCP consent card)
server?: string
action?: string
reason?: string
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
smart_denied?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
// terminal.read.request / preview.read.request (GUI agent reading the
// in-app terminal pane or the browser/preview pane)
start?: number
count?: number
// status.update (kind=process → background process completion/watch-match)
kind?: string
// pane.reveal (agent focusing a desktop pane via the focus_pane tool)
pane?: string
// layout.apply (agent applying a layout preset via the apply_layout tool)
preset?: string
// tour.request (tour tool — agent-guided driver.js walkthrough). `action`
// and `steps` name the tour verb and step list; `surface` picks the app's
// own DOM vs the preview pane's guest page. tip.show (tip tool — one accent
// bubble with an arrow, no overlay) adds no fields of its own: it reuses
// `selector`/`side` here plus `text`/`title`, and carries no request_id
// because a tip is fire-and-forget.
surface?: string
selector?: string
side?: string
steps?: unknown
step_index?: number
// preview.act.request (drive_preview tool — agent clicking/typing/scrolling in
// the in-app browser). `action` names the verb and `selector` is shared with
// tour above; `ref` addresses an element from the last inventory.
ref?: string
submit?: boolean
key?: string
amount?: number
to?: string
max?: number
// message.reaction (agent reacting via the react_to_message tool) — the
// durable messages.id, that row's full reaction list after the write, and
// the row's role so a live (not-yet-round-tripped) message can be matched.
row_id?: number
reactions?: MessageReaction[]
role?: string
// session.title (live auto-title push) — stored session id + generated title
session_id?: string
title?: string
// session.info — the stored (durable) session id for this runtime session.
// Lets the desktop app map runtime→stored for background sessions it hasn't
// opened, so the sidebar working indicator updates without opening the chat.
stored_session_id?: string
// moa.reference / moa.aggregating (Mixture of Agents per-model relay)
label?: string
index?: number
aggregator?: string
// moa.progress / moa.phase (Mixture of Agents fan-out progress relay)
refs_done?: number
refs_total?: number
phase?: string
// message.complete — signals the final text was already previewed via
// interim_assistant_callback, so the UI can settle instead of duplicating.
response_previewed?: boolean
// message.complete with status "error" — `text` is streamed partial output
// (keep it visible), not the error string.
partial?: boolean
// message.complete with status "error" — the failed turn was retained
// backend-side and will replay through session.resume's inflight payload.
recoverable?: boolean
// Structured billing wall forwarded on message.complete when a turn fails
// with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock).
billing?: BillingBlock
failure_reason?: string
}
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessage, ChatMessagePart } from '@/lib/chat-messages'
import type { ComposerAttachment } from '@/store/composer'
import {
attachmentDisplayText,
attachmentId,
coalesceToolOnlyAssistants,
coerceThinkingText,
createToolMergeCache,
messageCreatedAt,
optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
toRuntimeMessage
} from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
const THUMB_URL = 'data:image/png;base64,dGh1bWI='
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
return { id: 'a', label: 'file.png', ...overrides }
}
describe('optimisticAttachmentRef', () => {
it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
// The raw data URL flows through extractEmbeddedImages → inline thumbnail,
// dodging the remote /api/media 403 an @image:<localpath> ref would hit.
expect(ref).toBe(DATA_URL)
})
it('prefers the downscaled thumbnail for the display ref when present', () => {
const ref = optimisticAttachmentRef(
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL, thumbnailUrl: THUMB_URL })
)
// The bubble is display-only; full bytes are read on demand and for upload.
expect(ref).toBe(THUMB_URL)
})
it('does not render a full path-backed image while its bounded thumbnail is pending', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBeNull()
})
it('does not use a path fallback for a non-data preview url', () => {
const ref = optimisticAttachmentRef(
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
)
expect(ref).toBeNull()
})
it('passes non-image attachments straight through to attachmentDisplayText', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
'@file:src/a.ts'
)
})
// Session switches / draft restores can leave undefined|null holes in the
// composer attachments array. AttachmentList already filters them (#49624),
// but the submit path maps the same array through these helpers — an unguarded
// hole threw "Cannot read properties of undefined (reading 'refText')",
// crashing the chat surface (blank pane). The helpers must no-op on holes.
it('returns null for an undefined attachment instead of throwing', () => {
expect(() => optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).not.toThrow()
expect(optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).toBeNull()
})
it('returns null for a null attachment instead of throwing', () => {
expect(optimisticAttachmentRef(null as unknown as ComposerAttachment)).toBeNull()
})
})
describe('attachmentDisplayText', () => {
it('returns null for undefined|null instead of reading .kind/.refText on a hole', () => {
expect(() => attachmentDisplayText(undefined as unknown as ComposerAttachment)).not.toThrow()
expect(attachmentDisplayText(undefined as unknown as ComposerAttachment)).toBeNull()
expect(attachmentDisplayText(null as unknown as ComposerAttachment)).toBeNull()
})
it('still resolves a normal file ref', () => {
expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts')
})
it('expands a review attachment into an anchored fenced block', () => {
const detail = JSON.stringify({
author: 'teknium1',
body: 'this cap looks wrong',
diffHunk: '@@ -1,2 +1,2 @@\n-const CAP = 5\n+const CAP = 50',
kind: 'review',
line: 12,
path: 'src/limits.ts',
prNumber: 123,
startLine: null,
url: 'https://github.com/o/r/pull/123#discussion_r1'
})
const block = attachmentDisplayText(attachment({ kind: 'review', detail, refText: '@url:`https://x`' }))
// The contract: anchor (file:line), author, body, and the hunk all ride.
expect(block).toContain('review-comment src/limits.ts:12')
expect(block).toContain('@teknium1')
expect(block).toContain('this cap looks wrong')
expect(block).toContain('const CAP = 50')
})
it('falls back to the url ref when a review detail is malformed', () => {
expect(attachmentDisplayText(attachment({ kind: 'review', detail: 'not json', refText: '@url:`https://x`' }))).toBe(
'@url:`https://x`'
)
})
})
describe('coerceThinkingText', () => {
it('strips streaming status prefixes from thinking deltas', () => {
expect(coerceThinkingText("◉_◉ processing... checking the user's request")).toBe("checking the user's request")
expect(coerceThinkingText('(¬‿¬) analyzing... reading the file')).toBe('reading the file')
})
it('drops empty thinking rewrite placeholder text', () => {
expect(
coerceThinkingText(
"◉_◉ processing... I don't see any current rewritten thinking or next thinking to process. Could you provide the thinking content you'd like me to rewrite?"
)
).toBe('')
})
})
describe('parseCommandDispatch', () => {
it('keeps the notice on a send directive (e.g. /goal set)', () => {
// The backend's /goal set returns {type:send, notice:"⊙ Goal set …", message}.
// Dropping the notice made /goal look like it did nothing in the desktop app.
const parsed = parseCommandDispatch({ type: 'send', notice: '⊙ Goal set', message: 'do the thing' })
expect(parsed).toEqual({ type: 'send', message: 'do the thing', notice: '⊙ Goal set' })
})
it('keeps message-only send directives working (no notice)', () => {
expect(parseCommandDispatch({ type: 'send', message: 'hi' })).toEqual({
type: 'send',
message: 'hi',
notice: undefined
})
})
it('parses a prefill directive with its notice (e.g. /undo)', () => {
const parsed = parseCommandDispatch({ type: 'prefill', notice: 'backed up 1 turn', message: 'edit me' })
expect(parsed).toEqual({ type: 'prefill', message: 'edit me', notice: 'backed up 1 turn' })
})
it('rejects a prefill directive missing its message', () => {
expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()
})
})
describe('parseSlashCommand', () => {
it('parses a single-line command', () => {
expect(parseSlashCommand('/some-skill do something')).toEqual({
arg: 'do something',
name: 'some-skill'
})
})
it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => {
expect(parseSlashCommand('/goal Write a Python script\nthat prints Hello World')).toEqual({
arg: 'Write a Python script\nthat prints Hello World',
name: 'goal'
})
})
it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => {
const context = 'summarize this:\n\nparagraph one\nparagraph two\n\nparagraph three'
expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({
arg: context,
name: 'some-skill'
})
})
it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => {
expect(parseSlashCommand('/goal\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' })
})
it('keeps truly empty slash input empty', () => {
expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' })
expect(parseSlashCommand('/ ')).toEqual({ arg: '', name: '' })
})
it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => {
expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' })
})
})
describe('attachmentId', () => {
it('normalizes a trailing slash on a url so a re-attach dedupes (#59305 P2)', () => {
expect(attachmentId('url', 'https://example.com/a')).toBe(attachmentId('url', 'https://example.com/a/'))
})
it('falls back to the trimmed raw value for a malformed url instead of throwing', () => {
expect(() => attachmentId('url', 'not a url')).not.toThrow()
expect(attachmentId('url', ' not a url ')).toBe(attachmentId('url', 'not a url'))
})
it('normalizes backslash path separators so a Windows and posix path dedupe', () => {
expect(attachmentId('file', 'a\\b.ts')).toBe(attachmentId('file', 'a/b.ts'))
})
it('normalizes a trailing slash on a folder path', () => {
expect(attachmentId('folder', 'src/app/')).toBe(attachmentId('folder', 'src/app'))
})
it('does not collapse a bare root path to an empty id', () => {
expect(attachmentId('folder', '/')).toBe('folder:/')
})
it('keeps distinct urls distinct', () => {
expect(attachmentId('url', 'https://example.com/a')).not.toBe(attachmentId('url', 'https://example.com/b'))
})
})
describe('messageCreatedAt', () => {
const NOW = Date.UTC(2026, 6, 28, 18, 0, 0)
it('reads the authoritative Unix-seconds timestamp (not ms)', () => {
// 1785282262s → July 2026, not the 1970 epoch a *1000-less read would give.
expect(messageCreatedAt({ timestamp: 1785282262 }, NOW).getFullYear()).toBe(2026)
})
it('falls back to now — never digs digits out of the id → "20663d ago" (1970)', () => {
// The old fallback did `new Date(Number(id.match(/\d+/)))`, so a session-style
// id like 20260728_184420_05e697 parsed to 20260728 *ms* = Jan 1970, showing
// as an absurd 20663-day age. A timestamp-less message is freshly created.
expect(messageCreatedAt({ timestamp: undefined }, NOW).getTime()).toBe(NOW)
})
it('treats a zero / non-finite timestamp as absent', () => {
expect(messageCreatedAt({ timestamp: 0 }, NOW).getTime()).toBe(NOW)
expect(messageCreatedAt({ timestamp: Number.NaN }, NOW).getTime()).toBe(NOW)
})
})
describe('toRuntimeMessage timeline metadata', () => {
it('does not expose a fabricated visible timestamp for timestamp-less history', () => {
const runtime = toRuntimeMessage({
id: 'old-message',
parts: [{ text: 'old', type: 'text' }],
role: 'assistant'
})
expect((runtime.metadata?.custom as { timelineTimestamp?: number }).timelineTimestamp).toBeUndefined()
})
})
describe('coalesceToolOnlyAssistants toolCallId uniqueness', () => {
// Regression contract for #87857: two individually-clean assistant rows can
// share a toolCallId (structural carry-over re-attaching a cached row's tool
// calls while the same turn also exists as a committed row). Folding them
// used to manufacture ONE message carrying the id twice — the exact shape
// that makes assistant-ui's useResources throw and crash-loop the pane.
const tool = (toolCallId: string): ChatMessagePart =>
({ type: 'tool-call', toolCallId, toolName: 'terminal', args: {} as never, argsText: '' }) as ChatMessagePart
const assistant = (id: string, parts: ChatMessagePart[]): ChatMessage =>
({ id, role: 'assistant', parts }) as unknown as ChatMessage
it('drops the copy the predecessor already carries, keeps the new call', () => {
const merged = coalesceToolOnlyAssistants(
[
assistant('committed-49-assistant', [
{ type: 'text', text: 'working' } as ChatMessagePart,
tool('call-a'),
tool('call-b')
]),
assistant('assistant-stream-49', [tool('call-b'), tool('call-c')])
],
createToolMergeCache()
)
expect(merged).toHaveLength(1)
const ids = merged[0].parts
.filter(part => part.type === 'tool-call')
.map(part => (part as { toolCallId: string }).toolCallId)
expect(ids).toEqual(['call-a', 'call-b', 'call-c'])
})
it('folds a clean follow-up unchanged', () => {
const merged = coalesceToolOnlyAssistants(
[
assistant('a1', [{ type: 'text', text: 'ok' } as ChatMessagePart, tool('call-a')]),
assistant('a2', [tool('call-b')])
],
createToolMergeCache()
)
expect(merged).toHaveLength(1)
const ids = merged[0].parts
.filter(part => part.type === 'tool-call')
.map(part => (part as { toolCallId: string }).toolCallId)
expect(ids).toEqual(['call-a', 'call-b'])
})
})
+599
View File
@@ -0,0 +1,599 @@
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 { normalize } from '@/lib/text'
import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
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)}`
}
/** A GitHub PR review-thread (`#discussion_r<id>`) or conversation
* (`#issuecomment-<id>`) deep link — the one paste shape that can resolve to
* a structured review attachment instead of a plain `@url:` chip. */
export const PR_COMMENT_URL_RE =
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+(?:\/[^#\s]*)?#(?:discussion_r|issuecomment-)\d+$/
/** The send-time expansion of a `review` attachment. `detail` holds the
* resolved comment as JSON (HermesPrComment shape); a malformed payload falls
* back to the attachment's URL ref so the send never throws. */
export function reviewCommentBlock(detail: string): null | string {
try {
const c = JSON.parse(detail)
const anchor = c.path
? `${c.path}${c.line ? `:${c.startLine && c.startLine !== c.line ? `${c.startLine}-` : ''}${c.line}` : ''}`
: `PR #${c.prNumber}`
const hunk = c.diffHunk ? `\n--- diff hunk ---\n${String(c.diffHunk).trim()}` : ''
return `\`\`\`review-comment ${anchor}\n@${c.author} on ${c.url}\n\n${String(c.body).trim()}${hunk}\n\`\`\``
} catch {
return null
}
}
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\`\`\``
}
// A resolved PR review comment: expand to a fenced block carrying the
// anchor (file:line), author, body, and — when present — the diff hunk the
// comment sits on, so "address this" needs no re-explaining what "this" is.
// A malformed payload falls through to the refText (the pasted URL).
if (attachment.kind === 'review' && attachment.detail) {
const block = reviewCommentBlock(attachment.detail)
if (block) {
return block
}
}
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 parseSlashCommand(command: string) {
// `[\s\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>`
// or a skill command with a long pasted context. The old `.*$` regex failed
// the whole match on any newline, so every multiline slash command parsed as
// an empty name and got swallowed (#41323, #55510). The backend and CLI both
// split on any whitespace (`split(maxsplit=1)`), so this is the parity fix.
const match = command.replace(/^\/+/, '').match(/^(\S+)([\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), display: str(row.display) }
: null
case 'send':
return typeof row.message === 'string'
? { type: 'send', message: row.message, notice: str(row.notice), display: str(row.display) }
: null
case 'prefill':
return typeof row.message === 'string' ? { type: 'prefill', message: row.message, notice: str(row.notice) } : 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)
}
// 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 }
} 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
}
+28
View File
@@ -0,0 +1,28 @@
// Routes `navigator.clipboard.writeText` through Electron IPC, since the
// renderer's clipboard API throws "Write permission denied" whenever the
// document loses focus (e.g. clicking a portaled Radix dropdown). The IPC
// path runs in the main process and is unconditional.
export function installClipboardShim() {
const ipc = window.hermesDesktop?.writeClipboard
if (!ipc || !navigator.clipboard) {
return
}
const native = navigator.clipboard.writeText?.bind(navigator.clipboard)
const writeText = async (text: string) => {
try {
await ipc(text)
} catch {
await native?.(text)
}
}
try {
Object.defineProperty(navigator.clipboard, 'writeText', { configurable: true, value: writeText, writable: true })
} catch {
// Browser refused override; primitives keep using the native API.
}
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { isCodeSkewRestartRequired } from './code-skew-error'
const SKEW_IPC =
'Error invoking remote method \'hermes:api\': Error: 503: {"detail":"Restart required: This process is running code from 08b4875f4a but the checkout on disk is now 48d2528066. The model picker would risk a stale-module crash — restart the Desktop-owned backend to load the new code (use Restart backend in Hermes Desktop, or quit and reopen the app)"}'
describe('isCodeSkewRestartRequired', () => {
it('detects the Models-page IPC 503 from a stale Desktop-owned backend', () => {
expect(isCodeSkewRestartRequired(new Error(SKEW_IPC))).toBe(true)
})
it('detects a bare FastAPI detail string', () => {
expect(isCodeSkewRestartRequired('Restart required: checkout drifted')).toBe(true)
})
it('ignores unrelated 503s and load failures', () => {
expect(isCodeSkewRestartRequired(new Error('503: Service Unavailable'))).toBe(false)
expect(isCodeSkewRestartRequired(new Error('Failed to load models'))).toBe(false)
expect(isCodeSkewRestartRequired(null)).toBe(false)
})
})
+12
View File
@@ -0,0 +1,12 @@
/** True when a backend error is the post-update stale-module 503 (#97046). */
export function isCodeSkewRestartRequired(error: unknown): boolean {
return /Restart required:/i.test(errorText(error))
}
function errorText(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return typeof error === 'string' ? error : String(error ?? '')
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { buildCommitChangelog, parseCommitHeader } from './commit-changelog'
describe('parseCommitHeader', () => {
it('extracts type, scope, and subject from a conventional header', () => {
expect(parseCommitHeader('feat(desktop): NSIS prereq detection page')).toEqual({
breaking: false,
scope: 'desktop',
subject: 'NSIS prereq detection page',
type: 'feat'
})
})
it('flags breaking changes via the `!` marker', () => {
expect(parseCommitHeader('feat(api)!: change endpoint shape')).toMatchObject({
breaking: true,
type: 'feat'
})
})
it('treats non-conventional commits as untyped with the full header as subject', () => {
expect(parseCommitHeader('Update README')).toEqual({
breaking: false,
scope: null,
subject: 'Update README',
type: null
})
})
it('ignores body lines and trims whitespace', () => {
expect(parseCommitHeader(' fix: handle null input \n\nMore detail')).toMatchObject({
subject: 'handle null input',
type: 'fix'
})
})
it('returns empty subject for blank input', () => {
expect(parseCommitHeader('')).toEqual({ breaking: false, scope: null, subject: '', type: null })
})
})
describe('buildCommitChangelog', () => {
it('groups commits into user-friendly buckets and capitalizes subjects', () => {
const groups = buildCommitChangelog([
{ summary: 'feat(desktop): add NSIS prereq detection page' },
{ summary: 'fix(sidebar): jitter when dragging' },
{ summary: 'perf: shave 200ms off cold start' },
{ summary: 'refactor: extract sidebar row component' }
])
expect(groups.map(g => g.id)).toEqual(['new', 'fixed', 'faster'])
expect(groups[0]).toMatchObject({ label: "What's new" })
expect(groups[0].items[0]).toBe('Add NSIS prereq detection page')
expect(groups[1].items[0]).toBe('Jitter when dragging')
})
it('hides chore/ci/docs/test commits', () => {
const groups = buildCommitChangelog([
{ summary: 'chore: bump deps' },
{ summary: 'ci: tweak workflow' },
{ summary: 'docs: spelling fix' },
{ summary: 'feat: real new feature' }
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toEqual(['Real new feature'])
})
it('routes unparseable commits to the "Other improvements" bucket', () => {
const groups = buildCommitChangelog([{ summary: 'Update sidebar styling' }])
expect(groups[0].id).toBe('other')
expect(groups[0].items).toEqual(['Update sidebar styling'])
})
it('falls back to a neutral placeholder when every commit is filtered or empty', () => {
const groups = buildCommitChangelog([{ summary: 'chore: bump' }, { summary: 'ci: stuff' }])
expect(groups).toEqual([{ id: 'other', items: ['Improvements and fixes'], label: 'In this update' }])
})
it('dedupes identical subjects and caps the items per group', () => {
const groups = buildCommitChangelog(
[
{ summary: 'fix: thing A' },
{ summary: 'fix: thing A' },
{ summary: 'fix: thing B' },
{ summary: 'fix: thing C' },
{ summary: 'fix: thing D' },
{ summary: 'fix: thing E' }
],
{ maxPerGroup: 3, maxTotal: 10 }
)
expect(groups[0].items).toEqual(['Thing A', 'Thing B', 'Thing C'])
})
it('caps total entries across buckets', () => {
const groups = buildCommitChangelog(
[
{ summary: 'feat: a' },
{ summary: 'feat: b' },
{ summary: 'fix: c' },
{ summary: 'fix: d' },
{ summary: 'perf: e' }
],
{ maxTotal: 3 }
)
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0)
expect(totalItems).toBe(3)
})
})
+179
View File
@@ -0,0 +1,179 @@
/**
* Tiny user-facing changelog builder. Takes a list of raw commit summaries,
* parses the Conventional Commits 1.0 header (`type(scope)!: subject`),
* filters internal noise (chore/ci/docs/...), and groups the rest into
* friendly buckets for end users (What's new, Fixed, Faster, Improved).
*
* Inlined (rather than depending on `conventional-commits-parser`) because
* that package's index re-exports a Node `stream` helper which won't load
* in the sandboxed Electron renderer, and its actual parse logic for the
* header is a small regex.
*/
import { capitalize } from '@/lib/text'
export type CommitGroupId = 'new' | 'fixed' | 'faster' | 'improved' | 'other'
export interface CommitGroup {
id: CommitGroupId
label: string
items: string[]
}
export interface ParsedCommit {
type: null | string
scope: null | string
breaking: boolean
subject: string
}
export interface CommitChangelogInput {
summary?: string
}
interface BuildOptions {
maxGroups?: number
maxPerGroup?: number
maxTotal?: number
}
const GROUP_META: Record<CommitGroupId, { label: string; order: number }> = {
new: { label: "What's new", order: 0 },
fixed: { label: 'Fixed', order: 1 },
faster: { label: 'Faster', order: 2 },
improved: { label: 'Improved', order: 3 },
other: { label: 'Other improvements', order: 4 }
}
const TYPE_TO_GROUP: Record<string, CommitGroupId> = {
feat: 'new',
feature: 'new',
fix: 'fixed',
bugfix: 'fixed',
hotfix: 'fixed',
revert: 'fixed',
perf: 'faster',
performance: 'faster',
refactor: 'improved',
a11y: 'improved',
ui: 'improved',
ux: 'improved'
}
const HIDDEN_TYPES = new Set([
'build',
'chore',
'ci',
'dep',
'deps',
'doc',
'docs',
'lint',
'release',
'style',
'test',
'tests',
'wip'
])
const FALLBACK_GROUP: CommitGroup = { id: 'other', items: ['Improvements and fixes'], label: 'In this update' }
const CONVENTIONAL_HEADER = /^(?<type>[a-zA-Z][a-zA-Z0-9_-]*)(?:\((?<scope>[^)]+)\))?(?<bang>!)?:\s+(?<subject>.+)$/
/** Parse a single commit header line per Conventional Commits 1.0. */
export function parseCommitHeader(raw: string): ParsedCommit {
const header = (raw ?? '').split(/\r?\n/, 1)[0].trim()
if (!header) {
return { breaking: false, scope: null, subject: '', type: null }
}
const match = CONVENTIONAL_HEADER.exec(header)
if (!match?.groups) {
return { breaking: false, scope: null, subject: header, type: null }
}
return {
breaking: Boolean(match.groups.bang),
scope: match.groups.scope ?? null,
subject: match.groups.subject.trim(),
type: match.groups.type.toLowerCase()
}
}
function tidySubject(subject: string): string {
const cleaned = subject
.replace(/\s+/g, ' ')
.replace(/[.;,\s]+$/, '')
.trim()
if (!cleaned) {
return cleaned
}
return capitalize(cleaned)
}
/**
* Build a small grouped changelog from a list of raw commits.
* Always returns at least one group; falls back to a neutral placeholder
* when every commit was filtered or unparseable.
*/
export function buildCommitChangelog(
commits: readonly CommitChangelogInput[] | undefined,
options: BuildOptions = {}
): CommitGroup[] {
const { maxGroups = 3, maxPerGroup = 4, maxTotal = 6 } = options
const groups = new Map<CommitGroupId, string[]>()
const seen = new Set<string>()
let total = 0
for (const commit of commits ?? []) {
if (total >= maxTotal) {
break
}
const parsed = parseCommitHeader(commit.summary ?? '')
if (parsed.type && HIDDEN_TYPES.has(parsed.type)) {
continue
}
const groupId: CommitGroupId = parsed.type ? (TYPE_TO_GROUP[parsed.type] ?? 'other') : 'other'
const subject = tidySubject(parsed.subject)
if (!subject) {
continue
}
const dedupeKey = subject.toLowerCase()
if (seen.has(dedupeKey)) {
continue
}
const bucket = groups.get(groupId) ?? []
if (bucket.length >= maxPerGroup) {
continue
}
bucket.push(subject)
groups.set(groupId, bucket)
seen.add(dedupeKey)
total += 1
}
const result = Array.from(groups.entries())
.map(([id, items]) => ({ id, items, label: GROUP_META[id].label, order: GROUP_META[id].order }))
.sort((a, b) => a.order - b.order)
.slice(0, maxGroups)
.map(({ id, items, label }): CommitGroup => ({ id, items, label }))
if (result.length === 0) {
return [FALLBACK_GROUP]
}
return result
}
+500
View File
@@ -0,0 +1,500 @@
// Completion sound bank for agent turn-end cues.
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
import { getAudioContext } from '@/lib/audio-context'
import { ownsAmbientCue } from '@/store/ambient'
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
import { $hapticsMuted } from '@/store/haptics'
type OscType = OscillatorType
// One enveloped oscillator voice → master. Linear attack into an exponential
// decay keeps the tail smooth and avoids the click you get ramping to zero.
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const peak = spec.gain ?? 0.5
const attack = spec.attack ?? 0.006
const end = start + spec.dur
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Soft pluck: brief triangle strike with an upward glide into the bloom.
function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.004
const glide = spec.glide ?? 0.16
const end = start + spec.decay
osc.type = 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Slow-swell harmonic bloom — the dreamy tail after the pluck.
function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const hold = spec.hold ?? 0.08
const end = start + spec.attack + hold + spec.decay
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
if (spec.freqTo) {
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6)
}
osc.detune.setValueAtTime(spec.detune ?? 0, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack)
env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// One-shot white-noise source of a given length, the raw material for the
// bandpassed air/whoosh gestures below.
function noiseSource(ac: AudioContext, seconds: number): AudioBufferSourceNode {
const length = Math.floor(ac.sampleRate * seconds)
const buffer = ac.createBuffer(1, length, ac.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < length; i += 1) {
data[i] = Math.random() * 2 - 1
}
const source = ac.createBufferSource()
source.buffer = buffer
return source
}
// A whisper of bandpassed noise for PS5-menu airiness.
function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) {
const source = noiseSource(ac, 0.12)
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freq, start)
filter.Q.setValueAtTime(spec.q ?? 1.2, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Filtered noise sweep — soft send / whoosh gestures.
function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) {
const source = noiseSource(ac, 0.4)
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freqFrom, start)
filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end)
filter.Q.setValueAtTime(spec.q ?? 0.8, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Pitch-sweep chirp — modem / sci-fi gestures.
function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.003
const end = start + spec.decay
osc.type = spec.type ?? 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
let reverbImpulse: AudioBuffer | null = null
// Subtle wet send so the chimes sit in a room rather than a tin can. The impulse
// is generated once and cached; each play gets a fresh, disposable convolver.
function makeReverb(ac: AudioContext): ConvolverNode {
if (!reverbImpulse) {
const seconds = 1.6
const length = Math.floor(ac.sampleRate * seconds)
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
for (let channel = 0; channel < 2; channel += 1) {
const data = reverbImpulse.getChannelData(channel)
for (let i = 0; i < length; i += 1) {
// White noise with a steep exponential decay → smooth, short tail.
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6
}
}
}
const convolver = ac.createConvolver()
convolver.buffer = reverbImpulse
return convolver
}
export interface CompletionSoundVariant {
id: number
name: string
// `master` is warm (runs through low-pass + room tail).
play: (ac: AudioContext, master: GainNode, t0: number) => void
}
// Note frequencies (equal temperament). Everything lives in a low-mid register
// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
const A2 = 110
const A3 = 220
const A4 = 440
const A5 = 880
const B5 = 987.77
const C3 = 130.81
const C4 = 261.63
const E4 = 329.63
const E5 = 659.25
const E6 = 1318.51
const G4 = 392
const G5 = 783.99
const C5 = 523.25
const C6 = 1046.5
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
{
id: 1,
name: 'Two-note comfort',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
}
},
{
id: 2,
name: 'Glass ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' })
voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' })
airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 })
}
},
{
id: 3,
name: 'Soft marimba',
play: (ac, master, t0) => {
pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 })
bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 })
bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 })
}
},
{
id: 4,
name: 'Tri-tone message',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' })
}
},
{
id: 5,
name: 'Airy whoosh',
play: (ac, master, t0) => {
whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 })
voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' })
}
},
{
id: 6,
name: 'Discovery cluster',
play: (ac, master, t0) => {
const clusterDetunes = [-14, -5, 0, 7, 12]
clusterDetunes.forEach((detune, i) => {
bloomVoice(ac, master, t0 + i * 0.03, {
freq: A3,
gain: 0.012,
attack: 0.38,
hold: 0.12,
decay: 1.05,
detune
})
})
bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 })
}
},
{
id: 7,
name: 'Systems online',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' })
}
},
{
id: 8,
name: 'IBM terminal',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' })
voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' })
}
},
{
id: 9,
name: 'Modem chirp',
play: (ac, master, t0) => {
sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' })
sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' })
}
},
{
id: 10,
name: 'Wind chimes',
play: (ac, master, t0) => {
const chimes = [G5, C6, E5, A5]
chimes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.13, {
freq: frequency,
dur: 0.72,
gain: 0.028 - i * 0.003,
attack: 0.003,
type: 'sine'
})
})
}
},
{
id: 11,
name: 'Singing bowl',
play: (ac, master, t0) => {
bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 })
bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 })
bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 })
}
},
{
id: 12,
name: 'Harp lift',
play: (ac, master, t0) => {
const notes = [C5, E5, G5, C6]
notes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.075, {
freq: frequency,
dur: 0.38,
gain: 0.034 - i * 0.004,
attack: 0.012,
type: 'sine'
})
})
bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 })
}
},
{
id: 13,
name: 'Sonar ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' })
airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 })
}
},
{
id: 14,
name: 'Music box',
play: (ac, master, t0) => {
const notes = [E6, C6, G5, E5]
notes.forEach((frequency, i) => {
pluckVoice(ac, master, t0 + i * 0.09, {
freqFrom: frequency,
freqTo: frequency * 0.998,
gain: 0.02 - i * 0.002,
decay: 0.2,
glide: 0.06
})
})
}
}
] as const
function playVariant(variantId: number) {
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
if (!variant) {
return
}
const ac = getAudioContext()
if (!ac) {
return
}
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
const master = ac.createGain()
const tone = ac.createBiquadFilter()
tone.type = 'lowpass'
tone.frequency.setValueAtTime(3800, ac.currentTime)
tone.Q.setValueAtTime(0.32, ac.currentTime)
master.gain.setValueAtTime(0.48, ac.currentTime)
master.connect(tone)
const dry = ac.createGain()
dry.gain.setValueAtTime(0.88, ac.currentTime)
tone.connect(dry)
dry.connect(ac.destination)
const reverb = makeReverb(ac)
const wet = ac.createGain()
wet.gain.setValueAtTime(0.34, ac.currentTime)
tone.connect(reverb)
reverb.connect(wet)
wet.connect(ac.destination)
variant.play(ac, master, ac.currentTime + 0.01)
}
// Audition the selected variant from settings. Bypasses the haptics mute toggle so
// sound design can be compared even when turn-end cues are silenced.
export function previewCompletionSound(variantId?: number) {
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
}
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
// (the session id) so only one window beeps when several are open — the mute
// check runs first, so a muted window never claims the cue out from under an
// audible peer.
export function playCompletionSound(dedupeKey?: string) {
if ($hapticsMuted.get()) {
return
}
const play = () => playVariant($completionSoundVariantId.get())
if (!dedupeKey) {
return play()
}
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play())
}
interface AirPuffSpec {
decay: number
freq: number
gain: number
q?: number
start?: number
}
interface BloomSpec {
attack: number
decay: number
detune?: number
freq: number
freqTo?: number
gain: number
hold?: number
start?: number
type?: OscType
}
interface PluckSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
glide?: number
start?: number
}
interface SweepSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
start?: number
type?: OscType
}
interface ToneSpec {
attack?: number
dur: number
freq: number
gain?: number
start?: number
type?: OscType
}
interface WhooshSpec {
decay: number
freqFrom: number
freqTo: number
gain: number
q?: number
start?: number
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
collapseRepeatedInputArtifacts,
sanitizeComposerInput,
stripLeakedBracketedPasteWrappers
} from './composer-input-sanitize'
describe('stripLeakedBracketedPasteWrappers', () => {
it('leaves plain text unchanged', () => {
expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world')
})
it('strips canonical escape wrappers', () => {
expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello')
})
it('keeps embedded literal bracket forms', () => {
const text = 'literal[200~tag and literal[201~tag should stay'
expect(stripLeakedBracketedPasteWrappers(text)).toBe(text)
})
})
describe('collapseRepeatedInputArtifacts', () => {
it('removes the desktop corruption tail from #62557', () => {
const prefix = '需要时随时叫我。'
const tail = '[e~[[e' + '~[[e'.repeat(20)
expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix)
})
it('preserves a mid-string marker followed by valid suffix', () => {
const text = 'notes ~[[e more text here'
expect(collapseRepeatedInputArtifacts(text)).toBe(text)
})
it('preserves trailing punctuation that is not the corruption signature', () => {
expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....')
})
it('does not strip when fewer than minRepeats markers appear at the tail', () => {
const text = 'hello~[[e~[[e'
expect(collapseRepeatedInputArtifacts(text)).toBe(text)
})
})
describe('sanitizeComposerInput', () => {
it('normalizes wrappers and repeated artifact tails together', () => {
const corrupted = 'hello[' + '~[[e'.repeat(8)
expect(sanitizeComposerInput(corrupted)).toBe('hello')
})
})
@@ -0,0 +1,74 @@
/**
* Strip terminal bracketed-paste leaks and repeated artifact tails from composer
* text before it is shown in the UI or sent to the gateway.
*
* Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path).
*/
const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\])])\[200~/g
const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g
const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\])])00~/g
const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g
const DESKTOP_PASTE_ARTIFACT = '~[[e'
/** Strip leaked bracketed-paste wrapper markers from user-visible text. */
export function stripLeakedBracketedPasteWrappers(text: string): string {
if (!text) {
return text
}
let cleaned = text
// eslint-disable-next-line no-control-regex -- terminal data may contain control chars
.replace(/\x1b\[200~/g, '')
// eslint-disable-next-line no-control-regex -- terminal data may contain control chars
.replace(/\x1b\[201~/g, '')
.replace(/\^\[\[200~/g, '')
.replace(/\^\[\[201~/g, '')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '')
return cleaned
}
/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */
export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string {
if (!text) {
return text
}
const marker = DESKTOP_PASTE_ARTIFACT
let index = text.length
let repeatCount = 0
while (index >= marker.length && text.slice(index - marker.length, index) === marker) {
repeatCount += 1
index -= marker.length
}
if (repeatCount < minRepeats) {
return text
}
let start = index
if (start >= 2 && text.slice(start - 2, start) === '[e') {
start -= 2
} else if (start >= 1 && text[start - 1] === '[') {
start -= 1
}
return text.slice(0, start)
}
/** Normalize composer text before submit or draft persistence. */
export function sanitizeComposerInput(text: string): string {
if (!text) {
return text
}
return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text))
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import type { DesktopRegistryConnection } from '@/global'
import {
connectionEndpoint,
connectionMatchesQuery,
connectionTooltip,
sortConnectionsForDisplay
} from './connection-display'
const connection = (
id: string,
label: string,
kind: DesktopRegistryConnection['kind'] = 'remote'
): DesktopRegistryConnection => ({ id, kind, label, tokenPreview: null, tokenSet: false })
describe('connection display helpers', () => {
it('anchors local first and sorts labels case-insensitively with numeric order', () => {
const sorted = sortConnectionsForDisplay([
connection('remote-10', 'Studio 10'),
connection('zulu', 'zulu'),
connection('remote-2', 'studio 2'),
connection('local', 'This device', 'local'),
connection('alpha', 'Alpha')
])
expect(sorted.map(item => item.id)).toEqual(['local', 'alpha', 'remote-2', 'remote-10', 'zulu'])
})
it('does not mutate registry order', () => {
const connections = [connection('zulu', 'Zulu'), connection('alpha', 'Alpha')]
sortConnectionsForDisplay(connections)
expect(connections.map(item => item.id)).toEqual(['zulu', 'alpha'])
})
it('matches labels, transport details, localized aliases, and accents', () => {
const gateway: DesktopRegistryConnection = {
...connection('studio', 'Studio Genève', 'ssh'),
host: 'studio.example.test',
org: 'Editorial',
port: 2222,
remoteProfile: 'production',
user: 'hermes'
}
expect(connectionMatchesQuery(gateway, 'GENEVE')).toBe(true)
expect(connectionMatchesQuery(gateway, 'studio 2222')).toBe(true)
expect(connectionMatchesQuery(gateway, 'ssh studio')).toBe(true)
expect(connectionMatchesQuery(gateway, 'ssh')).toBe(true)
expect(connectionMatchesQuery(gateway, 'example.test')).toBe(true)
expect(connectionMatchesQuery(gateway, '2222')).toBe(true)
expect(connectionMatchesQuery(gateway, 'editorial production')).toBe(true)
expect(connectionMatchesQuery(gateway, 'distant', ['Passerelle distante'])).toBe(true)
expect(connectionMatchesQuery(gateway, 'homelab')).toBe(false)
})
it('keeps technical endpoints available on demand without exposing secrets', () => {
const remote = {
...connection('work', 'Work gateway'),
tokenPreview: 'sec...ret',
tokenSet: true,
url: 'https://work.example.test:9443'
}
const ssh = {
...connection('studio', 'Studio over SSH', 'ssh'),
host: 'studio.example.test',
keyPath: '/secret/key',
port: 2222,
user: 'hermes'
}
expect(connectionEndpoint(remote)).toBe('https://work.example.test:9443')
expect(connectionTooltip(remote)).toBe('Work gateway\nhttps://work.example.test:9443')
expect(connectionTooltip(remote)).not.toContain('sec...ret')
expect(connectionEndpoint(ssh)).toBe('hermes@studio.example.test:2222')
expect(connectionTooltip(ssh)).not.toContain('/secret/key')
expect(connectionTooltip(connection('local', 'This device', 'local'))).toBe('This device')
})
})
@@ -0,0 +1,82 @@
import type { DesktopRegistryConnection } from '@/global'
export const CONNECTION_SEARCH_THRESHOLD = 8
const connectionLabelCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base'
})
/** Local stays anchored; every other gateway has one stable, human-readable order. */
export function sortConnectionsForDisplay<T extends Pick<DesktopRegistryConnection, 'id' | 'kind' | 'label'>>(
connections: readonly T[]
): T[] {
return [...connections].sort((left, right) => {
const localOrder = Number(right.kind === 'local') - Number(left.kind === 'local')
return (
localOrder ||
connectionLabelCollator.compare(left.label, right.label) ||
connectionLabelCollator.compare(left.id, right.id)
)
})
}
function normalizeSearchText(value: string): string {
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase()
}
/** Search non-secret details users can see or reasonably remember about a gateway. */
export function connectionMatchesQuery(
connection: DesktopRegistryConnection,
query: string,
aliases: readonly string[] = []
): boolean {
const needles = normalizeSearchText(query).trim().split(/\s+/).filter(Boolean)
if (needles.length === 0) {
return true
}
const haystack = [
connection.label,
connection.kind,
connection.url,
connection.host,
connection.user,
connection.port == null ? null : String(connection.port),
connection.org,
connection.remoteProfile,
...aliases
]
.filter((value): value is string => Boolean(value))
.join(' ')
const normalizedHaystack = normalizeSearchText(haystack)
return needles.every(needle => normalizedHaystack.includes(needle))
}
/** Human-readable, non-secret endpoint for on-demand gateway details. */
export function connectionEndpoint(connection: DesktopRegistryConnection): null | string {
if (connection.kind === 'ssh') {
const host = connection.host?.trim()
if (!host) {
return null
}
const authority = `${connection.user?.trim() ? `${connection.user.trim()}@` : ''}${host}`
return connection.port == null ? authority : `${authority}:${connection.port}`
}
return connection.url?.trim() || null
}
/** Full gateway identity for a hover tip without keeping technical routing in chrome. */
export function connectionTooltip(connection: DesktopRegistryConnection): string {
const endpoint = connectionEndpoint(connection)
return endpoint ? `${connection.label}\n${endpoint}` : connection.label
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { connectionScopeSuffix } from './connection-scoped'
const remote = (profile: string, baseUrl = 'https://gw.example:8443') => ({
baseUrl,
mode: 'remote' as const,
profile
})
describe('connectionScopeSuffix', () => {
it('is empty for a local connection', () => {
expect(connectionScopeSuffix({ baseUrl: 'http://127.0.0.1:8000', mode: 'local', profile: 'default' })).toBe('')
})
it('includes the profile by default so profile-local lists stay apart', () => {
expect(connectionScopeSuffix(remote('default'))).toBe(
`.remote.${encodeURIComponent('https://gw.example:8443')}.default`
)
expect(connectionScopeSuffix(remote('k9'))).not.toBe(connectionScopeSuffix(remote('default')))
})
it('is stable across profile switch when includeProfile is false', () => {
const a = connectionScopeSuffix(remote('default'), false)
const b = connectionScopeSuffix(remote('k9'), false)
expect(a).toBe(`.remote.${encodeURIComponent('https://gw.example:8443')}`)
expect(a).toBe(b)
})
it('still isolates different gateways when includeProfile is false', () => {
expect(connectionScopeSuffix(remote('default', 'https://gw-a.example'), false)).not.toBe(
connectionScopeSuffix(remote('default', 'https://gw-b.example'), false)
)
})
})
+221
View File
@@ -0,0 +1,221 @@
import { atom, type WritableAtom } from 'nanostores'
import { type Codec, Codecs } from './persisted'
import { readKey, writeKey } from './storage'
// ── Connection-scoped persistence ───────────────────────────────────────────
// Multiple Desktop windows share one renderer origin — and therefore one
// localStorage area — while each window can be connected to a DIFFERENT
// gateway (primary vs per-session secondaries, local vs remote registry
// connections). Any gateway-bound list persisted under a single global key is
// silently shared between those windows, and per-window reconciliation (pin
// sync, session ordering) then bleeds one backend's state into another's
// sidebar (#77318).
//
// Persisted state must declare its scope in its own key. This module owns the
// CONNECTION scope: a `connectionScopedAtom` behaves like `persistentAtom`,
// but its storage key follows the active connection. The local connection
// keeps the BARE key — byte-identical behavior for single-backend users, the
// same contract as `backendScopeKey` in @hermes/shared — while a remote
// connection gets `<key>.remote.<encoded baseUrl>.<encoded profile>` by
// default (the shape `workspaceCwdKey` already established). Gateway-wide
// mirrors (pins) pass `{ includeProfile: false }` so a profile switch cannot
// fragment the cache and re-assert a stale copy.
//
// Legacy globally-keyed values are deliberately NOT migrated into remote
// scopes: those keys accumulated writes from every window, so ownership of
// any given row is unknowable — adopting them wholesale is exactly the
// cross-connection bleed this scope prevents (see the #67709 precedent for
// profile keys). Backend-mirrored lists (pins) self-heal from the gateway's
// own rows instead.
/** Minimal slice of HermesConnection this module keys on. */
export interface ConnectionScopeDescriptor {
baseUrl?: string
mode?: 'local' | 'remote'
profile?: null | string
}
export interface ConnectionScopeOptions {
/**
* When false, a remote suffix is `.remote.<baseUrl>` only. Use this for
* gateway-wide mirrors (pins) so a profile switch cannot reload a stale
* per-profile copy. Defaults to true — session order and other
* profile-local lists stay isolated.
*/
includeProfile?: boolean
}
/** The storage-key suffix for a connection. Local (and unknown) connections
* map to the bare key; remote connections get their own namespace. */
export function connectionScopeSuffix(
connection: ConnectionScopeDescriptor | null | undefined,
includeProfile = true
): string {
if (connection?.mode !== 'remote') {
return ''
}
const base = encodeURIComponent(connection.baseUrl || 'remote')
if (!includeProfile) {
return `.remote.${base}`
}
const profile = encodeURIComponent(connection.profile || 'default')
return `.remote.${base}.${profile}`
}
interface ScopedEntry<T> {
$value: WritableAtom<T>
codec: Codec<T>
fallback: T
includeProfile: boolean
key: string
/** Last suffix this entry loaded or persisted under. */
suffix: string
/** True while a rescope is applying a loaded value — the persistence
* subscriber must not echo that read back into storage. */
applying: boolean
}
let activeConnection: ConnectionScopeDescriptor | null | undefined
let activeSuffix = ''
let activeGatewaySuffix = ''
const registry: ScopedEntry<any>[] = []
const scopeListeners = new Set<() => void>()
function suffixFor(entry: Pick<ScopedEntry<unknown>, 'includeProfile'>): string {
return connectionScopeSuffix(activeConnection, entry.includeProfile)
}
/** The suffix for the connection the window is currently on. */
export function activeConnectionScopeSuffix(): string {
return activeSuffix
}
/** Observe gateway-identity changes (fires BEFORE scoped atoms that
* follow the connection reload, so pin-sync bookkeeping can reset).
* Profile-only switches do not fire: pin state is gateway-wide. */
export function onConnectionScopeChange(listener: () => void): () => void {
scopeListeners.add(listener)
return () => void scopeListeners.delete(listener)
}
function loadEntry<T>(entry: ScopedEntry<T>): T {
const raw = readKey(entry.key + suffixFor(entry))
if (raw === null) {
return entry.fallback
}
try {
return entry.codec.decode(raw)
} catch {
return entry.fallback
}
}
/**
* A `persistentAtom` whose storage key carries the active connection scope.
* Reads seed from the current scope's key; writes land under it. When the
* window's connection changes, every scoped atom reloads from the new scope.
*/
export function connectionScopedAtom<T>(
key: string,
fallback: T,
codec: Codec<T> = Codecs.json<T>(),
options?: ConnectionScopeOptions
): WritableAtom<T> {
const includeProfile = options?.includeProfile !== false
const entry: ScopedEntry<T> = {
$value: atom<T>(fallback),
applying: false,
codec,
fallback,
includeProfile,
key,
suffix: connectionScopeSuffix(activeConnection, includeProfile)
}
entry.$value.set(loadEntry(entry))
registry.push(entry)
// Persist CHANGES only — same creation-emission and rescope suppression
// rationale as persistentAtom: echoing a just-read value back out can
// clobber a storage snapshot another window is about to read.
let creationEmission = true
entry.$value.subscribe(value => {
if (creationEmission) {
creationEmission = false
return
}
if (entry.applying) {
return
}
writeKey(entry.key + suffixFor(entry), entry.codec.encode(value))
})
return entry.$value
}
/**
* Point every connection-scoped atom at `connection`'s storage scope.
*
* Called whenever the window's connection descriptor is published. A null
* descriptor is an ordinary disconnect/reconnect state — not evidence the
* user selected another backend — so it keeps the current scope (the same
* contract as syncCronModelImpactConnection).
*/
export function rescopeConnectionScopedStores(connection: ConnectionScopeDescriptor | null | undefined): void {
if (!connection) {
return
}
const next = connectionScopeSuffix(connection)
const nextGateway = connectionScopeSuffix(connection, false)
if (next === activeSuffix) {
return
}
activeConnection = connection
activeSuffix = next
// Pin-sync's mirrored/pending sets describe the PREVIOUS gateway. Fire
// only when the connection (not the profile) changes — pins are
// gateway-wide, so a profile switch must not reset bookkeeping and then
// re-PATCH a leftover per-profile copy.
if (nextGateway !== activeGatewaySuffix) {
activeGatewaySuffix = nextGateway
for (const listener of scopeListeners) {
listener()
}
}
for (const entry of registry) {
const entrySuffix = suffixFor(entry)
if (entrySuffix === entry.suffix) {
continue
}
entry.suffix = entrySuffix
entry.applying = true
try {
entry.$value.set(loadEntry(entry))
} finally {
entry.applying = false
}
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { resolveDeepLinkAction } from './deeplink-routes'
describe('resolveDeepLinkAction', () => {
it('routes unified plugin install deeplinks', () => {
expect(
resolveDeepLinkAction({
kind: 'plugin',
name: 'install',
params: { repo: 'owner/repo', enable: '0', force: '1' }
})
).toEqual({
type: 'plugin-install',
repo: 'owner/repo',
enable: false,
force: true,
legacyHint: null
})
})
it('routes legacy plugin-agent alias', () => {
expect(
resolveDeepLinkAction({
kind: 'plugin-agent',
name: '',
params: { repo: 'owner/repo' }
})
).toMatchObject({ type: 'plugin-install', legacyHint: 'agent' })
})
it('routes blueprint composer inserts', () => {
expect(
resolveDeepLinkAction({
kind: 'blueprint',
name: 'morning-brief',
params: { time: '08:00' }
})
).toEqual({
type: 'composer-blueprint',
name: 'morning-brief',
params: { time: '08:00' }
})
})
})
+66
View File
@@ -0,0 +1,66 @@
import type { PluginInstallLegacyHint } from '@/store/plugin-install-request'
export interface DeepLinkPayload {
kind: string
name: string
params: Record<string, string>
}
export type DeepLinkAction =
| { type: 'plugin-install'; repo: string; enable: boolean; force: boolean; legacyHint: PluginInstallLegacyHint }
| { type: 'composer-blueprint'; name: string; params: Record<string, string> }
| { type: 'ignore' }
function truthyParam(value: string | undefined, defaultValue = false): boolean {
if (value === undefined || value === '') {
return defaultValue
}
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
export function resolveDeepLinkAction(payload: DeepLinkPayload | null | undefined): DeepLinkAction {
if (!payload?.kind) {
return { type: 'ignore' }
}
if (payload.kind === 'blueprint' && payload.name) {
return { type: 'composer-blueprint', name: payload.name, params: payload.params || {} }
}
const repo = (payload.params?.repo || payload.params?.identifier || payload.name || '').trim()
if (payload.kind === 'plugin' && payload.name === 'install' && repo) {
return {
type: 'plugin-install',
repo,
enable: truthyParam(payload.params?.enable, true),
force: truthyParam(payload.params?.force, false),
legacyHint: null
}
}
if (payload.kind === 'plugin-agent' && repo) {
return {
type: 'plugin-install',
repo,
enable: truthyParam(payload.params?.enable, true),
force: truthyParam(payload.params?.force, false),
legacyHint: 'agent'
}
}
if (payload.kind === 'plugin-desktop' && repo) {
return {
type: 'plugin-install',
repo,
enable: truthyParam(payload.params?.enable, true),
force: truthyParam(payload.params?.force, false),
legacyHint: 'desktop'
}
}
return { type: 'ignore' }
}
+321
View File
@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setApiRequestConnection } from '@/api/client'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopFileDiff,
desktopFsCacheKey,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileDataUrlLocalFirst,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) {
return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
}
if (path.startsWith('/api/fs/read-text?')) {
return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
}
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
}
if (path.startsWith('/api/fs/git-root?')) {
return { root: '/remote' }
}
if (path === '/api/fs/default-cwd') {
return { cwd: '/backend/project', branch: 'main' }
}
if (path.startsWith('/api/git/file-diff?')) {
return { diff: 'remote diff' }
}
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
setApiRequestConnection(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setApiRequestConnection(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local', profile: 'team-local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({
entries: [{ name: 'local', path: '/local', isDirectory: true }]
})
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true, profile: 'team-local' })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('does not retry the same unreadable path through the local facade', async () => {
const error = new Error('not readable')
$connection.set({ mode: 'local' } as never)
readFileDataUrl.mockRejectedValueOnce(error)
await expect(readDesktopFileDataUrlLocalFirst('/missing.png')).rejects.toBe(error)
expect(readFileDataUrl).toHaveBeenCalledOnce()
expect(api).not.toHaveBeenCalled()
})
it('falls back from local disk to the active gateway in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
readFileDataUrl.mockRejectedValueOnce(new Error('not on host'))
await expect(readDesktopFileDataUrlLocalFirst('/remote/image.png')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
expect(readFileDataUrl).toHaveBeenCalledOnce()
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fremote%2Fimage.png' })
})
it('targets the active profile backend so a remote profile never reads local disk', async () => {
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
await readDesktopDir('/srv/project')
await desktopDefaultCwd()
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fsrv%2Fproject', profile: 'remote-docker' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd', profile: 'remote-docker' })
})
it('pins SSH filesystem reads to the active registry connection', async () => {
$connection.set({
connectionId: 'work-ssh',
mode: 'remote',
profile: 'default',
remoteKind: 'ssh'
} as never)
setApiRequestConnection('work-ssh')
await readDesktopFileDataUrl('/srv/project/image.png')
expect(api).toHaveBeenCalledWith({
connectionId: 'work-ssh',
path: '/api/fs/read-data-url?path=%2Fsrv%2Fproject%2Fimage.png',
profile: 'default'
})
})
it('pins remote filesystem requests to the active registry connection', async () => {
$connection.set({ connectionId: 'mr-small', mode: 'remote', profile: 'default' } as never)
setApiRequestConnection('mr-small')
await readDesktopDir('/home/doug/default-profile-workspace')
await readDesktopFileText('/home/doug/default-profile-workspace/IDEA.md')
await readDesktopFileDataUrl('/home/doug/default-profile-workspace/IDEA.md')
await desktopGitRoot('/home/doug/default-profile-workspace')
await desktopDefaultCwd()
await desktopFileDiff('/home/doug/default-profile-workspace', 'IDEA.md')
expect(api).toHaveBeenCalledTimes(6)
for (const [request] of api.mock.calls) {
expect(request).toMatchObject({ connectionId: 'mr-small', profile: 'default' })
}
})
it('separates filesystem cache keys for registered connections sharing a profile', () => {
$connection.set({
baseUrl: 'https://gateway.example',
connectionId: 'mr-small',
mode: 'remote',
profile: 'default'
} as never)
const mrSmallKey = desktopFsCacheKey()
$connection.set({
baseUrl: 'https://gateway.example',
connectionId: 'other-default',
mode: 'remote',
profile: 'default'
} as never)
expect(desktopFsCacheKey()).not.toBe(mrSmallKey)
})
it('prefers registry connection identity over SSH host identity', () => {
$connection.set({
baseUrl: 'http://127.0.0.1:41001',
connectionId: 'connection-a',
mode: 'remote',
remoteHost: 'operator@remote-box',
remoteKind: 'ssh',
remoteIdentity: 'operator@remote-box',
profile: 'default'
} as never)
const first = desktopFsCacheKey()
$connection.set({
baseUrl: 'http://127.0.0.1:52002',
connectionId: 'connection-b',
mode: 'remote',
remoteHost: 'operator@remote-box',
remoteKind: 'ssh',
remoteIdentity: 'operator@remote-box',
profile: 'default'
} as never)
expect(desktopFsCacheKey()).not.toBe(first)
})
it('keys SSH filesystem caches by stable host identity instead of the forwarded port', () => {
$connection.set({
mode: 'remote',
remoteKind: 'ssh',
remoteHost: 'operator@remote-box',
baseUrl: 'http://127.0.0.1:41001'
} as never)
const first = desktopFsCacheKey()
$connection.set({
mode: 'remote',
remoteKind: 'ssh',
remoteHost: 'operator@remote-box',
baseUrl: 'http://127.0.0.1:52002'
} as never)
expect(desktopFsCacheKey()).toBe(first)
expect(first).toContain('operator@remote-box')
expect(first).not.toContain('41001')
})
it('separates SSH filesystem caches by ownership and profile', () => {
$connection.set({
mode: 'remote',
remoteKind: 'ssh',
remoteHost: 'host-a',
remoteIdentity: 'owner-a',
profile: 'one'
} as never)
const first = desktopFsCacheKey()
$connection.set({
mode: 'remote',
remoteKind: 'ssh',
remoteHost: 'host-a',
remoteIdentity: 'owner-b',
profile: 'one'
} as never)
const otherOwner = desktopFsCacheKey()
$connection.set({
mode: 'remote',
remoteKind: 'ssh',
remoteHost: 'host-a',
remoteIdentity: 'owner-a',
profile: 'two'
} as never)
expect(otherOwner).not.toBe(first)
expect(desktopFsCacheKey()).not.toBe(first)
})
it('routes file diffs through backend git in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(desktopFileDiff('/repo', 'src/a b.ts')).resolves.toBe('remote diff')
expect(api).toHaveBeenCalledWith({ path: '/api/git/file-diff?path=%2Frepo&file=src%2Fa%20b.ts' })
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('uses the local Electron picker for remote file selection', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote', profile: 'team-remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual(['/local'])
expect(selectPaths).toHaveBeenCalledWith({ directories: false, multiple: false, profile: 'team-remote' })
expect(remoteSelect).not.toHaveBeenCalled()
})
it('limits the remote picker to single-directory selection', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/remote/project'])
expect(remoteSelect).toHaveBeenCalledWith({ directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
})
+224
View File
@@ -0,0 +1,224 @@
import { hermesApi } from '@/api/client'
import type {
HermesConnection,
HermesReadDirResult,
HermesReadFileTextResult,
HermesSelectPathsOptions
} from '@/global'
import { $connection } from '@/store/session'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
// A profile belongs to a registry connection, not the whole Desktop. The
// registry id is the isolation boundary, including for SSH connections; the
// stable host identity below is only the fallback for legacy connections.
if (connection.connectionId) {
return `connection:${connection.connectionId}:${connection.profile || ''}`
}
const target =
connection.remoteKind === 'ssh'
? connection.remoteIdentity || connection.remoteHost || ''
: connection.baseUrl || ''
return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}`
}
export function desktopFsCacheKey(connection: HermesConnection | null = $connection.get()) {
return connectionCacheKey(connection)
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
// Active profile for FS/git REST calls. Without it the Electron api bridge
// hits the primary (local) backend even when the user switched to a remote profile.
export function desktopFsProfile(): string | undefined {
return $connection.get()?.profile || undefined
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
function remoteFsApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
return hermesApi<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
if (!isDesktopFsRemoteMode()) {
return bridge().readDir(path)
}
return remoteFsApi<HermesReadDirResult>(fsPath('list', path))
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
if (!isDesktopFsRemoteMode()) {
return bridge().readFileText(path)
}
return remoteFsApi<HermesReadFileTextResult>(fsPath('read-text', path))
}
// Save UTF-8 text back to a file. Local writes go through the hardened Electron
// IPC; remote writes hit the dashboard's POST /api/fs/write-text (same path
// hardening, parent-must-exist, size cap) so the editor behaves identically in
// both modes. Stale-on-disk detection is the caller's job (re-read before save).
export async function writeDesktopFileText(path: string, content: string): Promise<{ path: string }> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
if (!desktop.writeTextFile) {
throw new Error('Saving is not available')
}
return desktop.writeTextFile(path, content)
}
const result = await remoteFsApi<{ ok?: boolean; path?: string }>('/api/fs/write-text', { content, path })
return { path: result.path || path }
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
if (!isDesktopFsRemoteMode()) {
return bridge().readFileDataUrl(path)
}
const result = await remoteFsApi<string | { dataUrl?: string }>(fsPath('read-data-url', path))
return typeof result === 'string' ? result : result.dataUrl || ''
}
/**
* Read a composer image local-shell first, even when the active agent is
* remote. Picker, clipboard, and OS-drop paths belong to this machine; in-app
* project-tree paths may belong only to the gateway and fall back there.
*/
export async function readDesktopFileDataUrlLocalFirst(path: string): Promise<string> {
try {
const local = await window.hermesDesktop?.readFileDataUrl?.(path)
if (local) {
return local
}
} catch (error) {
if (!isDesktopFsRemoteMode()) {
throw error
}
// Not on this machine (or unreadable locally) — try the active gateway.
}
return readDesktopFileDataUrl(path)
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
return (await remoteFsApi<{ root: string | null }>(fsPath('git-root', path))).root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return remoteFsApi<{ branch: string; cwd: string }>('/api/fs/default-cwd')
}
// Reveal a path in the OS file manager (Finder / Explorer / Files). Local only.
export async function revealDesktopPath(path: string): Promise<void> {
await bridge().revealPath?.(path)
}
// Rename a file/folder in place; returns the new absolute path. Local only.
export async function renameDesktopPath(path: string, newName: string): Promise<string> {
const desktop = bridge()
if (!desktop.renamePath) {
throw new Error('Rename is not available')
}
const result = await desktop.renamePath(path, newName)
return result.path
}
// Move a file/folder to the OS trash (recoverable). Local only.
export async function trashDesktopPath(path: string): Promise<void> {
const desktop = bridge()
if (!desktop.trashPath) {
throw new Error('Delete is not available')
}
await desktop.trashPath(path)
}
export async function copyTextToClipboard(text: string): Promise<void> {
await bridge().writeClipboard(text)
}
// Working-tree-vs-HEAD diff for one file. Empty when unchanged / not a repo.
// Remote gateway → backend git (/api/git/file-diff); local → Electron git.
export async function desktopFileDiff(repoRoot: string, filePath: string): Promise<string> {
if (isDesktopFsRemoteMode()) {
const result = await remoteFsApi<{ diff: string }>(
`/api/git/file-diff?path=${encodeURIComponent(repoRoot)}&file=${encodeURIComponent(filePath)}`
)
return result.diff || ''
}
const git = bridge().git
return git?.fileDiff ? git.fileDiff(repoRoot, filePath) : ''
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
const profile = desktopFsProfile()
const localOptions = profile ? { ...options, profile } : options
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(localOptions)
}
if (!options?.directories) {
return desktop.selectPaths(localOptions)
}
return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : []
}
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setApiRequestConnection } from '@/hermes'
import { $connection } from '@/store/session'
import { desktopGit } from './desktop-git'
const repoStatus = vi.fn(async () => ({ branch: 'main' }))
const worktreeList = vi.fn(async () => [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/r' }])
const localGit = { repoStatus, review: { stage: vi.fn() }, worktreeList }
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/git/status')) {
return { branch: 'remote-main' }
}
if (path.startsWith('/api/git/worktrees')) {
return { worktrees: [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' }] }
}
if (path.startsWith('/api/git/review/diff')) {
return { diff: 'remote-diff' }
}
if (path.startsWith('/api/git/branches')) {
return {
branches: [{ checkedOut: false, isDefault: false, isRemote: true, name: 'origin/feature', worktreePath: null }]
}
}
return { ok: true }
})
describe('desktop git facade', () => {
beforeEach(() => {
vi.stubGlobal('window', { hermesDesktop: { api, git: localGit } })
$connection.set(null)
})
afterEach(() => {
setApiRequestConnection(null)
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
})
it('returns undefined after the renderer global is torn down', () => {
vi.stubGlobal('window', undefined)
expect(desktopGit()).toBeUndefined()
})
it('uses Electron git locally', async () => {
$connection.set({ mode: 'local' } as never)
await expect(desktopGit()?.repoStatus('/work')).resolves.toEqual({ branch: 'main' })
expect(repoStatus).toHaveBeenCalledWith('/work')
expect(api).not.toHaveBeenCalled()
})
it('routes reads through the backend REST mirror on a remote gateway', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(desktopGit()?.repoStatus('/srv/work')).resolves.toEqual({ branch: 'remote-main' })
expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork' })
// List endpoints unwrap their envelope to the bare array the bridge returns.
await expect(desktopGit()?.worktreeList('/srv/work')).resolves.toEqual([
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/srv/r' }
])
// review.diff unwraps { diff } to a string.
await expect(desktopGit()?.review.diff('/srv/work', 'a.txt', 'uncommitted', null, false)).resolves.toBe(
'remote-diff'
)
expect(repoStatus).not.toHaveBeenCalled()
})
it('targets the active profile backend so a remote profile never touches the local repo', async () => {
$connection.set({ mode: 'remote', profile: 'remote-docker' } as never)
await desktopGit()?.repoStatus('/srv/work')
await desktopGit()?.review.stage('/srv/work', 'a.txt')
expect(api).toHaveBeenCalledWith({ path: '/api/git/status?path=%2Fsrv%2Fwork', profile: 'remote-docker' })
expect(api).toHaveBeenCalledWith({
body: { file: 'a.txt', path: '/srv/work' },
method: 'POST',
path: '/api/git/review/stage',
profile: 'remote-docker'
})
})
it('routes remote git reads and writes through the active registered gateway', async () => {
setApiRequestConnection('remote-user')
$connection.set({ mode: 'remote', profile: 'default' } as never)
await desktopGit()?.repoStatus('/srv/work')
await desktopGit()?.review.stage('/srv/work', 'a.txt')
expect(api).toHaveBeenCalledWith({
connectionId: 'remote-user',
path: '/api/git/status?path=%2Fsrv%2Fwork',
profile: 'default'
})
expect(api).toHaveBeenCalledWith({
body: { file: 'a.txt', path: '/srv/work' },
connectionId: 'remote-user',
method: 'POST',
path: '/api/git/review/stage',
profile: 'default'
})
})
it('sends mutations as POST bodies on a remote gateway', async () => {
$connection.set({ mode: 'remote' } as never)
await desktopGit()?.review.stage('/srv/work', 'a.txt')
expect(api).toHaveBeenCalledWith({
body: { file: 'a.txt', path: '/srv/work' },
method: 'POST',
path: '/api/git/review/stage'
})
expect(localGit.review.stage).not.toHaveBeenCalled()
})
// The ⌘⇧B "convert a branch into a worktree" flow (#81724): on a remote
// gateway both halves must reach the backend mirror — the picker's branch
// list (which now carries remote-tracking refs) and the worktree add that
// receives the picked `origin/…` name.
it('routes the convert-a-branch worktree flow through the backend on a remote gateway', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(desktopGit()?.branchList('/srv/work')).resolves.toEqual([
{ checkedOut: false, isDefault: false, isRemote: true, name: 'origin/feature', worktreePath: null }
])
expect(api).toHaveBeenCalledWith({ path: '/api/git/branches?path=%2Fsrv%2Fwork' })
await desktopGit()?.worktreeAdd('/srv/work', { existingBranch: 'origin/feature' })
expect(api).toHaveBeenCalledWith({
body: { existingBranch: 'origin/feature', path: '/srv/work' },
method: 'POST',
path: '/api/git/worktree/add'
})
})
})
+118
View File
@@ -0,0 +1,118 @@
import type {
HermesGitBaseBranch,
HermesGitBranch,
HermesGitWorktree,
HermesRepoPullRequests,
HermesRepoStatus,
HermesReviewList,
HermesReviewShipInfo
} from '@/global'
import { hermesApi } from '@/hermes'
import { desktopFsProfile, isDesktopFsRemoteMode } from './desktop-fs'
// Remote-aware git facade. Locally the desktop runs git through Electron
// (window.hermesDesktop.git); on a remote gateway that's the wrong filesystem,
// so we mirror the same surface over the dashboard REST API (/api/git/*) — the
// coding rail, worktree lanes, review pane, and branch ops then act on the
// BACKEND repo where sessions actually run. Mirrors desktop-fs.ts.
type GitBridge = NonNullable<NonNullable<Window['hermesDesktop']>['git']>
function desktopApi<T>(path: string, body?: Record<string, unknown>): Promise<T> {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return hermesApi<T>(
body ? { body, method: 'POST', path, profile: desktopFsProfile() } : { path, profile: desktopFsProfile() }
)
}
function gitGet<T>(route: string, params: Record<string, boolean | null | string | undefined>): Promise<T> {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
query.set(key, String(value))
}
}
return desktopApi<T>(`/api/git/${route}?${query.toString()}`)
}
function gitPost<T>(route: string, body: Record<string, unknown>): Promise<T> {
return desktopApi<T>(`/api/git/${route}`, body)
}
const remoteGit: GitBridge = {
worktreeList: async repoPath =>
(await gitGet<{ worktrees: HermesGitWorktree[] }>('worktrees', { path: repoPath })).worktrees,
worktreeAdd: (repoPath, options) => gitPost('worktree/add', { path: repoPath, ...options }),
worktreeRemove: (repoPath, worktreePath, options) =>
gitPost('worktree/remove', { force: options?.force ?? false, path: repoPath, worktreePath }),
branchSwitch: (repoPath, branch) => gitPost('branch/switch', { branch, path: repoPath }),
branchList: async repoPath =>
(await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches,
baseBranchList: async repoPath =>
(await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches,
repoStatus: repoPath => gitGet<HermesRepoStatus | null>('status', { path: repoPath }),
fileDiff: async (repoPath, filePath) =>
(await gitGet<{ diff: string }>('file-diff', { file: filePath, path: repoPath })).diff,
review: {
list: (repoPath, scope, baseRef) =>
gitGet<HermesReviewList>('review/list', { base: baseRef, path: repoPath, scope }),
diff: async (repoPath, filePath, scope, baseRef, staged) =>
(await gitGet<{ diff: string }>('review/diff', { base: baseRef, file: filePath, path: repoPath, scope, staged }))
.diff,
stage: (repoPath, filePath) => gitPost('review/stage', { file: filePath ?? null, path: repoPath }),
unstage: (repoPath, filePath) => gitPost('review/unstage', { file: filePath ?? null, path: repoPath }),
revert: (repoPath, filePath) => gitPost('review/revert', { file: filePath ?? null, path: repoPath }),
revParse: async (repoPath, ref) =>
(await gitGet<{ sha: null | string }>('review/rev-parse', { path: repoPath, ref })).sha,
commit: (repoPath, message, push) => gitPost('review/commit', { message, path: repoPath, push }),
commitContext: repoPath => gitGet('review/commit-context', { path: repoPath }),
push: repoPath => gitPost('review/push', { path: repoPath }),
shipInfo: repoPath => gitGet<HermesReviewShipInfo>('review/ship-info', { path: repoPath }),
prList: (repoPath, branches, numbers) =>
gitPost<HermesRepoPullRequests>('review/pr-list', { branches, numbers: numbers ?? [], path: repoPath }),
// Remote gateways have no PR-comment route yet; resolve to null so the
// paste degrades to a plain URL instead of throwing mid-paste.
fetchPrComment: async () => null,
createPr: repoPath => gitPost('review/create-pr', { path: repoPath })
},
// Repo discovery is a local-disk crawl; on a remote gateway the backend
// already merges session-derived repos, so this is a no-op.
scanRepos: async () => []
}
export function desktopGit(): GitBridge | undefined {
if (typeof window === 'undefined') {
return undefined
}
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { deriveRemoteAuthProviderShape } from './desktop-remote-auth'
describe('deriveRemoteAuthProviderShape', () => {
it('uses fallback copy when the gateway has not reported providers', () => {
expect(deriveRemoteAuthProviderShape(null)).toEqual({
isPassword: false,
providerLabel: 'your identity provider'
})
expect(deriveRemoteAuthProviderShape([], 'the configured gateway')).toEqual({
isPassword: false,
providerLabel: 'the configured gateway'
})
})
it('marks providers as password-style only when every provider supports password login', () => {
expect(
deriveRemoteAuthProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
).toEqual({
isPassword: true,
providerLabel: 'Username & Password'
})
})
it('keeps OAuth copy for redirect providers and mixed deployments', () => {
expect(
deriveRemoteAuthProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])
).toEqual({
isPassword: false,
providerLabel: 'Nous Research'
})
expect(
deriveRemoteAuthProviderShape([
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
])
).toEqual({
isPassword: false,
providerLabel: 'Username & Password / Nous Research'
})
})
it('falls back to provider names when display names are missing', () => {
expect(deriveRemoteAuthProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }])).toEqual({
isPassword: true,
providerLabel: 'basic'
})
})
})
@@ -0,0 +1,26 @@
import type { DesktopAuthProvider } from '@/global'
export interface RemoteAuthProviderShape {
isPassword: boolean
providerLabel: string
}
function providerDisplayName(provider: DesktopAuthProvider): string {
return provider.displayName || provider.name
}
export function deriveRemoteAuthProviderShape(
providers: DesktopAuthProvider[] | null | undefined,
fallback = 'your identity provider'
): RemoteAuthProviderShape {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: fallback }
}
return {
isPassword: list.every(provider => Boolean(provider.supportsPassword)),
providerLabel: list.map(providerDisplayName).join(' / ')
}
}
@@ -0,0 +1,467 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
type CommandCatalogMeta,
type CommandsCatalogLike,
desktopSkinSlashCompletions,
type DesktopSlashArgumentMode,
desktopSlashCommandArgumentMode,
desktopSlashDescription,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion,
isModelPickerCommand,
isPickerCommand,
rankSkillCommands,
rememberDesktopCommandsCatalog,
resolveDesktopCommand,
slashCompletionGroup
} from './desktop-slash-commands'
function registryCatalog(
modes: Record<string, DesktopSlashArgumentMode | null>,
aliases: Record<string, string> = {}
): CommandsCatalogLike {
const commands: Record<string, CommandCatalogMeta> = {}
const canon: Record<string, string> = {}
for (const [name, argument_mode] of Object.entries(modes)) {
commands[name] = { argument_mode, desktop: null }
canon[name] = name
}
for (const [alias, target] of Object.entries(aliases)) {
commands[alias] = commands[target]
canon[alias] = target
}
return { commands, canon }
}
const REGISTRY_CATALOG = registryCatalog(
{
'/approvals': 'options',
'/review': 'text',
'/refine': 'text',
'/usage': null,
'/version': null,
'/agents': null,
'/steer': 'text',
'/stop': null,
'/bg': 'text',
'/btw': 'text',
'/debug': null,
'/goal': 'mixed',
'/personality': 'options',
'/queue': 'text',
'/retry': null,
'/rollback': null,
'/tools': 'options',
'/undo': null,
'/loop': 'mixed',
'/lcm': 'text'
},
{ '/tasks': '/agents', '/background': '/bg', '/q': '/queue', '/proactive': '/loop' }
)
describe('desktop slash command curation', () => {
beforeEach(() => {
rememberDesktopCommandsCatalog(REGISTRY_CATALOG)
})
afterEach(() => {
rememberDesktopCommandsCatalog(undefined)
})
it('keeps core desktop chat commands in suggestions', () => {
expect(isDesktopSlashSuggestion('/new')).toBe(true)
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
expect(isDesktopSlashSuggestion('/approvals')).toBe(true)
expect(isDesktopSlashCommand('/approvals')).toBe(true)
expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' })
expect(isDesktopSlashSuggestion('/review')).toBe(true)
expect(isDesktopSlashCommand('/review')).toBe(true)
expect(resolveDesktopCommand('/review')?.surface).toEqual({ kind: 'exec' })
expect(resolveDesktopCommand('/review')?.argumentMode).toBe('text')
})
it('treats registry and plugin commands as exec when the catalog says so', () => {
expect(resolveDesktopCommand('/refine')?.argumentMode).toBe('text')
expect(isDesktopSlashSuggestion('/refine')).toBe(true)
expect(isDesktopSlashSuggestion('/background')).toBe(false)
expect(isDesktopSlashCommand('/bg')).toBe(true)
expect(desktopSlashCommandArgumentMode('/bg')).toBe('text')
expect(isDesktopSlashCommand('/btw')).toBe(true)
expect(desktopSlashCommandArgumentMode('/btw')).toBe('text')
expect(resolveDesktopCommand('/lcm')?.surface).toEqual({ kind: 'exec' })
expect(desktopSlashCommandArgumentMode('/lcm')).toBe('text')
})
it('groups complete.slash rows by backend kind, not the desktop table', () => {
// A registry command the table has never heard of is still a command.
expect(slashCompletionGroup('/refine', 'command')).toBe('Commands')
expect(slashCompletionGroup('/docx', 'skill')).toBe('Skills')
// Older backends omit kind — fall back to the table.
expect(slashCompletionGroup('/new')).toBe('Commands')
expect(slashCompletionGroup('/docx')).toBe('Skills')
})
it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => {
expect(isDesktopSlashSuggestion('/my-skill')).toBe(true)
expect(isDesktopSlashSuggestion('/gif-search')).toBe(true)
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
})
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
expect(isDesktopSlashSuggestion('/density')).toBe(false)
expect(isDesktopSlashSuggestion('/redraw')).toBe(false)
expect(isDesktopSlashSuggestion('/approve')).toBe(false)
expect(isDesktopSlashSuggestion('/model')).toBe(false)
expect(isDesktopSlashSuggestion('/skills')).toBe(false)
expect(isDesktopSlashSuggestion('/voice')).toBe(false)
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
})
it('/voice points at the composer voice button instead of the generic advanced message', () => {
// /voice arms server-side capture — on the desktop the composer's own
// voice conversation (mic menu / Ctrl+B) is the surface. A user typing
// /voice must be told where the button IS, not shrugged at.
expect(resolveDesktopCommand('/voice')?.surface).toEqual({ kind: 'unavailable', reason: 'composer-voice' })
expect(isDesktopSlashCommand('/voice')).toBe(false)
const message = desktopSlashUnavailableMessage('/voice')
expect(message).toContain('microphone button')
expect(message).toContain('Ctrl+B')
})
it('routes /compact to /compress (context compression), not the TUI display toggle', () => {
expect(resolveDesktopCommand('/compact')?.name).toBe('/compress')
expect(isDesktopSlashCommand('/compact')).toBe(true)
// Alias stays out of the popover so /compress is the single visible entry.
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
expect(isDesktopSlashSuggestion('/compress')).toBe(true)
})
it('surfaces /tools, /save, and /personality on the desktop', () => {
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
expect(isDesktopSlashSuggestion('/save')).toBe(true)
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
expect(isDesktopSlashCommand('/tools')).toBe(true)
expect(isDesktopSlashCommand('/save')).toBe(true)
expect(isDesktopSlashCommand('/personality')).toBe(true)
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('routes /pet through the desktop action handler and drops /pets', () => {
expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' })
expect(desktopSlashCommandArgumentMode('/pet')).toBe('options')
expect(isDesktopSlashSuggestion('/pet')).toBe(true)
expect(isDesktopSlashCommand('/pet')).toBe(true)
expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' })
expect(isDesktopSlashSuggestion('/pets')).toBe(false)
expect(isDesktopSlashCommand('/pets')).toBe(false)
})
it('routes /wake through the desktop wake action instead of the slash worker', () => {
expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' })
expect(desktopSlashCommandArgumentMode('/wake')).toBe('options')
expect(isDesktopSlashSuggestion('/wake')).toBe(true)
expect(isDesktopSlashCommand('/wake')).toBe(true)
expect(desktopSlashUnavailableMessage('/wake')).toBeNull()
})
it('routes /stop through the desktop action that cancels the active turn', () => {
expect(resolveDesktopCommand('/stop')?.surface).toEqual({ kind: 'action', action: 'stop' })
expect(isDesktopSlashSuggestion('/stop')).toBe(true)
expect(isDesktopSlashCommand('/stop')).toBe(true)
expect(desktopSlashUnavailableMessage('/stop')).toBeNull()
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
expect(isDesktopSlashCommand('/browser')).toBe(true)
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
// Bare /browser expands to its sub-action options in the popover.
expect(desktopSlashCommandArgumentMode('/browser')).toBe('options')
})
it('routes /compress through the session-compression action', () => {
// /compress must be an action (session.compress RPC), not exec: the slash
// worker route times out on large sessions (#44456).
expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(desktopSlashCommandArgumentMode('/compress')).toBe('text')
expect(isDesktopSlashCommand('/compress')).toBe(true)
expect(isDesktopSlashSuggestion('/compress')).toBe(true)
expect(desktopSlashUnavailableMessage('/compress')).toBeNull()
// /compact is an alias — executes but stays out of the popover.
expect(resolveDesktopCommand('/compact')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(isDesktopSlashCommand('/compact')).toBe(true)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
})
it('routes only stateless session commands through dedicated gateway RPCs', () => {
const expected = {
'/save': 'session.save',
'/status': 'session.status'
} as const
for (const [name, rpcName] of Object.entries(expected)) {
const surface = resolveDesktopCommand(name)?.surface
expect(surface?.kind).toBe('rpc')
if (surface?.kind !== 'rpc') {
continue
}
expect(surface.rpc).toBe(rpcName)
expect(surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })).toEqual({
session_id: 's-1'
})
}
})
it('keeps commands with richer CLI semantics on the slash worker', () => {
for (const name of ['/agents', '/steer', '/usage']) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('still routes commands without dedicated RPCs through exec()', () => {
// /btw is an action (prompt.btw) — the slash-worker print never reached Desktop.
const execNames = [
'/bg',
'/debug',
'/goal',
'/personality',
'/queue',
'/retry',
'/rollback',
'/tools',
'/undo',
'/version'
]
for (const name of execNames) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('routes /btw to the prompt.btw side-question action', () => {
expect(resolveDesktopCommand('/btw')?.surface).toEqual({ kind: 'action', action: 'btw' })
expect(isDesktopSlashCommand('/btw')).toBe(true)
expect(isDesktopSlashSuggestion('/btw')).toBe(true)
expect(desktopSlashUnavailableMessage('/btw')).toBeNull()
})
it('distinguishes free prose from finite slash option lists', () => {
expect(desktopSlashCommandArgumentMode('/goal')).toBe('mixed')
expect(desktopSlashCommandArgumentMode('/steer')).toBe('text')
expect(desktopSlashCommandArgumentMode('/queue')).toBe('text')
expect(desktopSlashCommandArgumentMode('/personality')).toBe('options')
expect(desktopSlashCommandArgumentMode('/handoff')).toBe('options')
expect(desktopSlashCommandArgumentMode('/version')).toBeNull()
})
it('routes /journey (and aliases) to the memory graph overlay action', () => {
expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/learning')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(isDesktopSlashCommand('/journey')).toBe(true)
expect(isDesktopSlashCommand('/memory-graph')).toBe(true)
expect(isDesktopSlashSuggestion('/journey')).toBe(true)
// Aliases execute but stay out of the popover.
expect(isDesktopSlashSuggestion('/memory-graph')).toBe(false)
expect(desktopSlashUnavailableMessage('/journey')).toBeNull()
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
})
it('filters built-in catalog noise but keeps skill / quick-command extensions', () => {
const filtered = filterDesktopCommandsCatalog({
categories: [
{
name: 'Session',
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen']
]
},
{
name: 'User commands',
pairs: [['/ship-it', 'Run release checklist']]
}
],
pairs: [
['/new', 'Start a new session'],
['/model', 'Switch model'],
['/ship-it', 'Run release checklist']
],
skill_count: 2
})
expect(filtered.categories).toEqual([
{ name: 'Session', pairs: [['/new', 'Start a new desktop chat']] },
{ name: 'User commands', pairs: [['/ship-it', 'Run release checklist']] }
])
expect(filtered.pairs).toEqual([
['/new', 'Start a new desktop chat'],
['/ship-it', 'Run release checklist']
])
// skill_count is recomputed from the filtered output (only /ship-it is an
// extension command — /new is a built-in) so the /help footer matches what
// the user actually sees rather than echoing the unfiltered backend total.
expect(filtered.skill_count).toBe(1)
})
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
const filtered = filterDesktopCommandsCatalog({
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen'],
['/gif-search', 'Search for a gif'],
['/ship-it', 'Run release checklist']
],
skill_count: 12
})
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
expect(filtered.skill_count).toBe(2)
})
it('uses desktop-specific labels for commands with different UI behavior', () => {
expect(desktopSlashDescription('/branch', 'Branch the current session')).toBe(
'Branch the latest message into a new chat'
)
expect(desktopSlashDescription('/skin', 'Show or change the display skin/theme')).toBe(
'Switch desktop theme or cycle to the next one'
)
})
it('builds /skin completions from desktop themes', () => {
const completions = desktopSkinSlashCompletions(
[
{ name: 'mono', label: 'Mono', description: 'Clean grayscale' },
{ name: 'midnight', label: 'Midnight', description: 'Deep blue' },
{ name: 'slate', label: 'Slate', description: 'Cool slate blue' }
],
'mono',
'm'
)
expect(completions).toEqual([
{
text: '/skin mono',
display: '/skin mono',
meta: 'Mono (current) - Clean grayscale'
},
{
text: '/skin midnight',
display: '/skin midnight',
meta: 'Midnight - Deep blue'
}
])
})
it('explains known commands that desktop owns elsewhere', () => {
expect(desktopSlashUnavailableMessage('/model sonnet')).toContain('model picker')
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
})
it('flags /model as a picker-owned command so the desktop opens the overlay', () => {
expect(isModelPickerCommand('/model')).toBe(true)
expect(isModelPickerCommand('/model sonnet')).toBe(true)
expect(isModelPickerCommand('/new')).toBe(false)
expect(isModelPickerCommand('/skills')).toBe(false)
})
it('gives /resume (and its aliases) a first-class session picker surface', () => {
expect(isPickerCommand('/resume', 'session')).toBe(true)
expect(isPickerCommand('/sessions', 'session')).toBe(true)
expect(isPickerCommand('/switch', 'session')).toBe(true)
// Unlike /model, /resume shows in the popover; its aliases stay hidden.
expect(isDesktopSlashSuggestion('/resume')).toBe(true)
expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
expect(isDesktopSlashCommand('/switch')).toBe(true)
// The session picker is distinct from the model picker.
expect(isModelPickerCommand('/resume')).toBe(false)
})
it('resolves commands and aliases to their declared surface', () => {
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
// Skill / quick commands aren't in the registry.
expect(resolveDesktopCommand('/gif-search')).toBeNull()
})
})
describe('rankSkillCommands', () => {
const rows = [
{ text: '/research' },
{ text: '/research-paper-writing' },
{ text: '/work' },
{ text: '/ship-it' },
{ text: '/manim-video' },
{ text: '/docx' }
]
const skills = {
'/research': { usage: 60, origin: 'local' as const },
'/research-paper-writing': { usage: 0, origin: 'bundled' as const },
'/work': { usage: 172, origin: 'local' as const },
'/manim-video': { usage: 0, origin: 'bundled' as const },
'/docx': { usage: 0, origin: 'local' as const }
}
it('puts the most-used skill first and breaks ties alphabetically', () => {
expect(rankSkillCommands(rows, skills).map(row => row.text)).toEqual([
'/work',
'/research',
'/docx',
'/manim-video',
'/research-paper-writing',
'/ship-it'
])
})
it('drops never-used built-ins when browsing, keeping everything else', () => {
const browsing = rankSkillCommands(rows, skills, { pruneUnusedBuiltins: true }).map(row => row.text)
expect(browsing).toEqual(['/work', '/research', '/docx', '/ship-it'])
// A user's own unused skill survives — only shipped-and-ignored goes.
expect(browsing).toContain('/docx')
// Unclassified rows (quick commands, skills newer than the map) survive too.
expect(browsing).toContain('/ship-it')
})
it('leaves the backend order untouched when the catalog carries no usage', () => {
expect(rankSkillCommands(rows, undefined, { pruneUnusedBuiltins: true })).toEqual(rows)
})
it('ranks an alias by the canonical command it resolves to', () => {
const ranked = rankSkillCommands([{ text: '/sessions' }, { text: '/research' }], {
'/research': { usage: 5, origin: 'local' },
'/resume': { usage: 900, origin: 'local' }
})
expect(ranked.map(row => row.text)).toEqual(['/sessions', '/research'])
})
})
@@ -0,0 +1,713 @@
import { peekCachedSlashCompletion } from '@/lib/slash-completion-cache'
export interface CommandsCatalogSection {
name: string
pairs: [string, string][]
}
export interface CommandCatalogMeta {
argument_mode?: 'mixed' | 'options' | 'text' | null
desktop?: string | null
}
export interface CommandsCatalogLike {
canon?: Record<string, string>
categories?: CommandsCatalogSection[]
commands?: Record<string, CommandCatalogMeta>
pairs?: [string, string][]
skill_count?: number
skills?: SkillCatalogMap
warning?: string
}
/**
* Per-skill ranking data from `commands.catalog`, keyed by slash command.
* Absent on older backends — every helper below degrades to "no ranking,
* hide nothing".
*/
export interface SkillCatalogEntry {
/** Where the skill came from; matches `/api/skills` provenance ('agent' = 'local'). */
origin?: 'bundled' | 'hub' | 'local'
/** Observed activity (use + view + patch) — the same number Capabilities shows. */
usage?: number
}
export type SkillCatalogMap = Record<string, SkillCatalogEntry>
export interface DesktopSlashCompletion {
display: string
meta: string
text: string
}
export interface DesktopThemeCommandOption {
description: string
label: string
name: string
}
/**
* Local client action a command resolves to. Each id maps to exactly one
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
* means adding a branch to a switch ladder — you add a row here + a handler
* keyed by the id.
*/
export type DesktopActionId =
| 'branch'
| 'browser'
| 'btw'
| 'compress'
| 'handoff'
| 'hatch'
| 'help'
| 'journey'
| 'new'
| 'pet'
| 'profile'
| 'skin'
| 'stop'
| 'title'
| 'wake'
| 'yolo'
/** A command fulfilled by opening a desktop overlay picker. */
export type DesktopPickerId = 'model' | 'session'
/** Why a known Hermes command has no desktop UI surface. */
export type DesktopUnavailableReason = 'advanced' | 'composer-voice' | 'messaging' | 'settings' | 'terminal'
/**
* How the desktop fulfils a command. This is the single discriminator the
* dispatcher, popover, pills, and pickers all read — no parallel block-lists.
*
* - `action` → handled by a local client handler (new chat, branch, …)
* - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
* resolved by that picker instead of falling through
* - `rpc` → dedicated gateway RPC named on the surface. The dispatcher
* calls it directly with the params built by `buildParams`,
* bypassing `slash.exec` / `command.dispatch`. Reserved for
* commands that have a first-class RPC handler in
* `tui_gateway/server.py` (e.g. `/save` → session.save).
* - `exec` → runs on the backend via slash.exec / command.dispatch and
* renders its text output inline. Only commands WITHOUT a
* dedicated RPC should stay here.
* - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
* messaging-only, …); shows a reason instead of executing
*/
export type DesktopCommandSurface =
| { kind: 'action'; action: DesktopActionId }
| { kind: 'picker'; picker: DesktopPickerId }
| {
kind: 'rpc'
rpc: string
timeoutMs?: number
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>
}
| { kind: 'exec' }
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
/**
* Inputs a `buildParams` function receives. The dispatcher passes session id,
* the typed arg, and the canonical command name so handlers can construct
* the exact JSON the gateway method expects.
*/
export interface SlashCommandBuildCtx {
arg: string
command: string
name: string
sessionId: string
}
/**
* How arguments behave in the Desktop composer.
*
* - `options` → a finite completion list; picking or fully typing an option may
* commit the complete directive as a chip.
* - `text` → arbitrary prose; the command and its argument stay editable.
* - `mixed` → offers subcommand completions but also accepts arbitrary prose.
*/
export type DesktopSlashArgumentMode = 'mixed' | 'options' | 'text'
export interface DesktopCommandSpec {
/** Canonical command, leading slash included (e.g. `/resume`). */
name: string
/** Popover/help label; omitted for unavailable commands (never surfaced). */
description?: string
aliases?: string[]
surface: DesktopCommandSurface
/**
* Hide from the slash popover / completions while still letting it execute.
* Used for picker commands reachable from chrome (the model picker lives on
* the status bar), so the popover doesn't dead-end on inline completion.
*/
hidden?: boolean
/** Composer behavior for text following the command token. */
argumentMode?: DesktopSlashArgumentMode
}
const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
/**
* Route a command directly to its dedicated gateway RPC. Prefer this over
* `exec()` whenever `tui_gateway/server.py` exposes a `@method(...)` for the
* command — bypassing `slash.exec` keeps the path short and the response
* structured.
*
* The dispatcher calls `requestGateway(surface.rpc, surface.buildParams(ctx))`
* and then runs `renderRpcResult` to format the response.
*/
const rpc = (
rpcName: string,
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>,
timeoutMs?: number
): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams })
/**
* Local desktop overlay — actions, pickers, and dedicated RPCs the Electron
* client owns. Registry commands without a row here are `exec` unless the
* catalog marks them unavailable/hidden. New commands and plugins declare
* `argument_mode` / `desktop` on the Python registry instead of adding a row.
*/
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// Local client actions
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
{
name: '/stop',
description: 'Stop the active turn and background processes',
surface: action('stop')
},
{
name: '/branch',
description: 'Branch the latest message into a new chat',
aliases: ['/fork'],
surface: action('branch')
},
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
{
name: '/wake',
description: 'Control the desktop wake-word listener [on|off|status]',
surface: action('wake'),
argumentMode: 'options'
},
{
name: '/handoff',
description: 'Hand off this session to a messaging platform',
surface: action('handoff'),
argumentMode: 'options'
},
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
{
name: '/skin',
description: 'Switch desktop theme or cycle to the next one',
surface: action('skin'),
argumentMode: 'options'
},
{ name: '/title', description: 'Rename the current session', surface: action('title'), argumentMode: 'text' },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
{
name: '/browser',
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
surface: action('browser'),
argumentMode: 'options'
},
{
name: '/journey',
description: 'Open the memory graph — skills + memories over time',
aliases: ['/learning', '/memory-graph'],
surface: action('journey')
},
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
{
name: '/resume',
description: 'Resume a saved session',
aliases: ['/sessions', '/switch'],
surface: picker('session'),
// `mixed`, not `options`: the argument is a free-text search the picker
// fuzzy-matches against titles and previews, so multi-word queries have to
// stay typeable. Its completion list also always carries a trailing
// "Browse all sessions…" action row, which meant Space-to-accept could
// never fall through — the first space wiped the composer and threw the
// user into the overlay.
argumentMode: 'mixed'
},
// /compress must be an action (session.compress RPC), not exec: the slash
// worker route times out on large sessions (30s WS / 45s pipe) before the
// LLM summarise call finishes, then command.dispatch surfaces a bogus
// "not a quick/plugin/skill command: compress" (#44456).
{
name: '/compress',
description: 'Compress this conversation context',
aliases: ['/compact'],
surface: action('compress'),
argumentMode: 'text'
},
// /btw must be an action (prompt.btw RPC), not exec: the slash worker
// prints the answer after process_command returns, so Desktop only ever
// saw the acknowledgement (#99065). The answer arrives as btw.complete.
{
name: '/btw',
description: 'Ask a side question about this conversation without interrupting it',
surface: action('btw'),
argumentMode: 'text'
},
{
name: '/pet',
description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)',
surface: action('pet'),
argumentMode: 'options'
},
{
name: '/hatch',
description: 'Generate a new pet (opens the pet generator)',
aliases: ['/generate-pet'],
surface: action('hatch')
},
{
name: '/save',
description: 'Save the current transcript to JSON',
surface: rpc('session.save', ctx => ({ session_id: ctx.sessionId }))
},
{
name: '/status',
description: 'Show current session status',
surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId }))
}
]
// Known commands with no desktop surface (and no alias) — a flat name list
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/busy',
'/clear',
'/config',
'/copy',
'/cron',
'/density',
'/details',
'/exit',
'/footer',
'/gateway',
'/history',
'/image',
'/indicator',
'/logs',
'/mouse',
'/paste',
'/platforms',
'/plugins',
'/quit',
'/redraw',
'/reload',
'/restart',
'/sb',
'/set-home',
'/sethome',
'/snap',
'/snapshot',
'/statusbar',
'/toolsets',
'/update',
'/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills', '/pets'],
advanced: [
'/curator',
'/fast',
'/insights',
'/kanban',
'/reasoning',
'/reload-mcp',
'/reload_mcp',
'/reload-skills',
'/reload_skills'
],
// /voice arms SERVER-side capture (voice.record → PortAudio on the backend
// host) — meaningless on desktop, which has its own composer-native voice
// conversation (mic menu / Ctrl+B) with client-side capture and playback.
// Point the user at the button instead of a generic "advanced" shrug.
'composer-voice': ['/voice']
}
const ALL_SPECS: readonly DesktopCommandSpec[] = [
...DESKTOP_COMMAND_SPECS,
...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
)
]
const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec]))
const ALIAS_TO_CANONICAL = new Map<string, string>(
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
)
let rememberedCatalog: CommandsCatalogLike | undefined
/** Last catalog the composer saw — used so Space/Enter know argument mode
* without waiting for another `/` keystroke. */
export function rememberDesktopCommandsCatalog(catalog: CommandsCatalogLike | undefined): void {
rememberedCatalog = catalog
}
function liveCatalog(): CommandsCatalogLike | undefined {
return rememberedCatalog ?? peekCachedSlashCompletion<CommandsCatalogLike>('catalog')
}
function catalogMeta(command: string): CommandCatalogMeta | undefined {
const commands = liveCatalog()?.commands
if (!commands) {
return undefined
}
const normalized = normalizeCommand(command)
const canonical = ALIAS_TO_CANONICAL.get(normalized) || catalogCanonical(normalized) || normalized
return commands[canonical] ?? commands[normalized]
}
function catalogCanonical(normalized: string): string | undefined {
const canon = liveCatalog()?.canon
if (!canon) {
return undefined
}
return canon[normalized] ?? canon[normalized.toLowerCase()]
}
function asUnavailableReason(value: string | null | undefined): DesktopUnavailableReason | null {
if (
value === 'advanced' ||
value === 'composer-voice' ||
value === 'messaging' ||
value === 'settings' ||
value === 'terminal'
) {
return value
}
return null
}
function asArgumentMode(value: string | null | undefined): DesktopSlashArgumentMode | undefined {
if (value === 'options' || value === 'text' || value === 'mixed') {
return value
}
return undefined
}
function specFromCatalog(command: string): DesktopCommandSpec | null {
const entry = catalogMeta(command)
if (!entry) {
return null
}
const name = canonicalDesktopSlashCommand(command)
const reason = asUnavailableReason(entry.desktop)
if (reason) {
return { name, surface: unavailable(reason) }
}
return {
name,
surface: exec(),
hidden: entry.desktop === 'hidden',
argumentMode: asArgumentMode(entry.argument_mode)
}
}
function isAliasCommand(command: string): boolean {
const normalized = normalizeCommand(command)
if (ALIAS_TO_CANONICAL.has(normalized)) {
return true
}
const resolved = catalogCanonical(normalized)
return Boolean(resolved && resolved.toLowerCase() !== normalized)
}
const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = {
advanced: command =>
`${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
'composer-voice': () =>
'Voice chat lives in the composer here: click the microphone button and choose "Start voice chat" (or press Ctrl+B).',
messaging: command => `${command} is only used from messaging platforms.`,
settings: command => `${command} is managed from the desktop sidebar.`,
terminal: command => `${command} is only available in the terminal interface.`
}
const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = {
model: command => `${command} uses the desktop model picker instead of a slash command.`,
session: command => `${command} uses the desktop session picker instead of a slash command.`
}
function normalizeCommand(command: string): string {
const trimmed = command.trim()
const base = (trimmed.startsWith('/') ? trimmed : `/${trimmed}`).split(/\s+/, 1)[0]?.toLowerCase() || ''
return base
}
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
return ALIAS_TO_CANONICAL.get(normalized) || catalogCanonical(normalized) || normalized
}
/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */
export function resolveDesktopCommand(command: string): DesktopCommandSpec | null {
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? specFromCatalog(command)
}
function isKnownHermesSlashCommand(command: string): boolean {
const normalized = normalizeCommand(command)
if (SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)) {
return true
}
return catalogMeta(normalized) !== undefined
}
/**
* An "extension" command is anything the backend surfaces that is NOT one of
* Hermes' built-in slash commands — i.e. skill commands (`/gif-search`,
* `/codex`, …) and user-defined quick commands. These are user-activated, so
* they appear in the desktop slash palette and execute when typed.
*/
export function isDesktopSlashExtensionCommand(command: string): boolean {
const normalized = normalizeCommand(command)
if (!normalized || normalized === '/') {
return false
}
return !isKnownHermesSlashCommand(normalized)
}
/**
* Popover group for a `complete.slash` row. The backend already tags each
* item `skill` | `command`; trust that so a new registry command isn't
* dumped into Skills just because this table has no row yet. Older backends
* omit `kind` — then the table is the fallback.
*/
export function slashCompletionGroup(command: string, kind?: string | null): 'Commands' | 'Skills' {
if (kind === 'skill') {
return 'Skills'
}
if (kind === 'command') {
return 'Commands'
}
return isDesktopSlashExtensionCommand(command) ? 'Skills' : 'Commands'
}
/** Gates execution: true unless the command is a known no-desktop-surface command. */
export function isDesktopSlashCommand(command: string): boolean {
const spec = resolveDesktopCommand(command)
if (spec) {
return spec.surface.kind !== 'unavailable'
}
return isDesktopSlashExtensionCommand(command)
}
/** Gates discovery in the popover/completions. */
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
// Aliases stay hidden so the popover isn't cluttered with duplicates.
if (isAliasCommand(normalized)) {
return false
}
const spec = resolveDesktopCommand(normalized)
if (spec) {
return spec.surface.kind !== 'unavailable' && !spec.hidden
}
// Skill / quick commands the backend provides.
return isDesktopSlashExtensionCommand(normalized)
}
/**
* True for commands the desktop fulfils by opening an overlay picker
* (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
*/
export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
const surface = resolveDesktopCommand(command)?.surface
if (surface?.kind !== 'picker') {
return false
}
return picker ? surface.picker === picker : true
}
/** Back-compat shim for the model picker check. */
export function isModelPickerCommand(command: string): boolean {
return isPickerCommand(command, 'model')
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const canonical = canonicalDesktopSlashCommand(command)
const surface = resolveDesktopCommand(canonical)?.surface
if (!surface) {
return null
}
if (surface.kind === 'unavailable') {
return UNAVAILABLE_MESSAGE[surface.reason](canonical)
}
if (surface.kind === 'picker') {
return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
}
export function desktopSlashCommandArgumentMode(command: string): DesktopSlashArgumentMode | null {
return resolveDesktopCommand(command)?.argumentMode ?? asArgumentMode(catalogMeta(command)?.argument_mode) ?? null
}
export function desktopSkinSlashCompletions(
themes: DesktopThemeCommandOption[],
activeThemeName: string,
argPrefix: string
): DesktopSlashCompletion[] {
const prefix = argPrefix.trim().toLowerCase()
const commands: DesktopSlashCompletion[] = [
{
text: '/skin list',
display: '/skin list',
meta: 'Show available desktop themes'
},
{
text: '/skin next',
display: '/skin next',
meta: 'Cycle to the next desktop theme'
},
...themes.map(theme => ({
text: `/skin ${theme.name}`,
display: `/skin ${theme.name}`,
meta: `${theme.label}${theme.name === activeThemeName ? ' (current)' : ''} - ${theme.description}`
}))
]
if (!prefix) {
return commands
}
return commands.filter(item => item.text.slice('/skin '.length).toLowerCase().startsWith(prefix))
}
/**
* Order skill rows by how much the user actually uses them, most-used first,
* A–Z within a tie. A `/` menu sorted alphabetically buries the handful of
* skills someone reaches for daily under a hundred they have never opened.
*
* `pruneUnusedBuiltins` additionally drops bundled skills with no recorded
* activity — the ones that ship with Hermes and were never asked for. It is
* for BROWSING (a bare `/`) only: typing a query is a search, and a search
* must never hide a match.
*
* Older backends send no `skills` map; then nothing is reordered or dropped.
*/
export function rankSkillCommands<T extends { text: string }>(
rows: readonly T[],
skills: SkillCatalogMap | undefined,
{ pruneUnusedBuiltins = false }: { pruneUnusedBuiltins?: boolean } = {}
): T[] {
if (!skills) {
return [...rows]
}
const entryOf = (row: T): SkillCatalogEntry | undefined => skills[canonicalDesktopSlashCommand(row.text)]
const usageOf = (row: T): number => entryOf(row)?.usage ?? 0
const kept = pruneUnusedBuiltins
? rows.filter(row => {
const entry = entryOf(row)
// Unknown to the map (a quick command, a newer skill the catalog
// hasn't classified) stays — only a confirmed never-used built-in goes.
return !entry || entry.origin !== 'bundled' || (entry.usage ?? 0) > 0
})
: [...rows]
return kept.sort((a, b) => usageOf(b) - usageOf(a) || a.text.localeCompare(b.text))
}
export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): CommandsCatalogLike {
rememberDesktopCommandsCatalog(catalog)
const categories = catalog.categories
?.map(section => ({
...section,
pairs: section.pairs
.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
}))
.filter(section => section.pairs.length > 0)
const pairs = catalog.pairs
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
// Recount skill commands from the filtered output so /help's footer reflects
// what the user actually sees. Backend's skill_count includes commands the
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
// like "60 skill commands available" while only ~29 appear in the list.
const filteredCommands = new Set<string>()
for (const section of categories ?? []) {
for (const [command] of section.pairs) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
}
for (const [command] of pairs ?? []) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
let skillCount = 0
for (const command of filteredCommands) {
if (isDesktopSlashExtensionCommand(command)) {
skillCount += 1
}
}
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
return {
...catalog,
...(categories ? { categories } : {}),
...(pairs ? { pairs } : {}),
...(hasSkillCount ? { skill_count: skillCount } : {})
}
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { isDesktopToolsetVisible } from './desktop-toolsets'
describe('isDesktopToolsetVisible', () => {
it('hides platform-coupled and internal toolsets', () => {
for (const name of ['discord', 'discord_admin', 'yuanbao', 'context_engine', 'moa']) {
expect(isDesktopToolsetVisible(name)).toBe(false)
}
})
it('keeps ordinary user-facing toolsets', () => {
for (const name of ['web', 'browser', 'terminal', 'file', 'memory', 'vision', 'image_gen']) {
expect(isDesktopToolsetVisible(name)).toBe(true)
}
})
})
+24
View File
@@ -0,0 +1,24 @@
// Curation for the desktop "Skills & Tools → Toolsets" list.
//
// `GET /api/tools/toolsets` returns the full CONFIGURABLE_TOOLSETS set with no
// desktop-specific filter — so it surfaces entries that don't belong in a flat
// per-user toggle list on the desktop: platform-coupled toolsets (which
// `hermes tools` already platform-restricts on the CLI) and internal plumbing
// that isn't a user-facing capability. Mirror the curation approach used for
// slash commands (`desktop-slash-commands.ts`): one documented block-list, one
// predicate. Hiding a toolset only removes its row — its enabled state and
// runtime gating are untouched.
const DESKTOP_HIDDEN_TOOLSETS = new Set([
// Platform-coupled — only meaningful when that platform is the active
// adapter; `hermes tools` restricts these off the CLI too.
'discord',
'discord_admin',
'yuanbao',
// Internal plumbing, not a user capability toggle.
'context_engine',
'moa'
])
export function isDesktopToolsetVisible(name: string): boolean {
return !DESKTOP_HIDDEN_TOOLSETS.has(name)
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { displayPath, normalizeDisplayPath, pathLeaf } from './display-path'
describe('displayPath', () => {
it('collapses a macOS home prefix to ~', () => {
expect(displayPath('/Users/brooklyn/www/hermes-agent')).toBe('~/www/hermes-agent')
expect(displayPath('/Users/brooklyn')).toBe('~')
})
it('collapses a Linux home prefix to ~', () => {
expect(displayPath('/home/alice/src/app')).toBe('~/src/app')
})
it('collapses a Windows user profile to ~', () => {
expect(displayPath('C:\\Users\\brooklyn\\src')).toBe('~/src')
expect(displayPath('C:/Users/brooklyn')).toBe('~')
})
it('honours an explicit home override', () => {
expect(displayPath('/opt/work/repo', { home: '/opt/work' })).toBe('~/repo')
expect(displayPath('/elsewhere/repo', { home: '/opt/work' })).toBe('/elsewhere/repo')
})
it('leaves non-home absolute paths alone', () => {
expect(displayPath('/var/log/system.log')).toBe('/var/log/system.log')
expect(displayPath('/Users')).toBe('/Users')
})
it('normalizes separators and trailing slashes', () => {
expect(normalizeDisplayPath('C:\\Users\\me\\src\\')).toBe('C:/Users/me/src')
expect(displayPath('/Users/me/src/')).toBe('~/src')
})
it('keeps an already-tildified path', () => {
expect(displayPath('~/www/app')).toBe('~/www/app')
expect(displayPath('~')).toBe('~')
})
})
describe('pathLeaf', () => {
it('returns the last segment', () => {
expect(pathLeaf('/Users/me/www/hermes-agent')).toBe('hermes-agent')
expect(pathLeaf('~/www/hermes-agent')).toBe('hermes-agent')
expect(pathLeaf('/')).toBe('/')
})
})
+157
View File
@@ -0,0 +1,157 @@
/**
* One place to format filesystem paths for DISPLAY.
*
* Industry standard (shells, VS Code `tildify`, Finder path bar): collapse the
* user's home directory to `~`, keep forward slashes, leave everything else
* alone. Copy/reveal/IPC still use the real absolute path — this is paint only.
*
* When `home` is unknown (renderer has no `os.homedir()` and remote cwd may
* not match the local machine), a conservative heuristic still collapses the
* common `/Users/<name>`, `/home/<name>`, and `C:/Users/<name>` prefixes so a
* long absolute path never paints raw in chrome.
*/
export interface DisplayPathOptions {
/** Explicit home directory to collapse (local machine home, remote $HOME). */
home?: null | string
}
/** Normalize separators and drop a trailing slash (except root / drive root). */
export function normalizeDisplayPath(raw: string): string {
let path = (raw || '').trim().replace(/\\/g, '/')
if (!path) {
return ''
}
// Collapse repeated slashes, but keep a leading UNC `//server/...` pair.
if (path.startsWith('//')) {
path = `//${path.slice(2).replace(/\/{2,}/g, '/')}`
} else {
path = path.replace(/\/{2,}/g, '/')
}
// Drop trailing slash except bare `/` or `C:/`.
if (path.length > 1 && path.endsWith('/') && !/^[A-Za-z]:\/$/.test(path)) {
path = path.replace(/\/+$/, '')
}
return path
}
function normalizeHome(home: string): string {
const normalized = normalizeDisplayPath(home)
if (!normalized) {
return ''
}
// Home itself should not keep a trailing slash for prefix checks.
return normalized.replace(/\/+$/, '')
}
function startsWithHome(path: string, home: string, caseInsensitive: boolean): boolean {
if (!home) {
return false
}
if (path === home) {
return true
}
const prefix = `${home}/`
return caseInsensitive
? path.toLowerCase().startsWith(prefix.toLowerCase()) || path.toLowerCase() === home.toLowerCase()
: path.startsWith(prefix) || path === home
}
/**
* Best-effort home prefix when callers don't pass one. Matches the usual
* single-user layouts; never collapses `/Users` or `/home` alone.
*/
function inferredHomePrefix(path: string): string {
// macOS: /Users/name[/...]
let match = path.match(/^(\/Users\/[^/]+)(?:\/|$)/)
if (match) {
return match[1]
}
// Linux (and most UNIX): /home/name[/...]
match = path.match(/^(\/home\/[^/]+)(?:\/|$)/)
if (match) {
return match[1]
}
// Windows user profile: C:/Users/name[/...] (also works after \ → /)
match = path.match(/^([A-Za-z]:\/Users\/[^/]+)(?:\/|$)/)
if (match) {
return match[1]
}
return ''
}
/**
* Format a filesystem path for UI chrome.
*
* /Users/brooklyn/www/hermes-agent → ~/www/hermes-agent
* /Users/brooklyn → ~
* C:\Users\brooklyn\src → ~/src
* /var/log → /var/log
* already/relative → already/relative
*/
export function displayPath(raw: null | string | undefined, options: DisplayPathOptions = {}): string {
const path = normalizeDisplayPath(raw || '')
if (!path) {
return ''
}
// Already tildified — normalize only.
if (path === '~' || path.startsWith('~/')) {
return path
}
const explicitHome = options.home ? normalizeHome(options.home) : ''
// Windows paths are case-insensitive; POSIX paths with an explicit home keep
// case-sensitive matching (Linux). Heuristic homes on mac/win ignore case.
const home = explicitHome || inferredHomePrefix(path)
if (!home) {
return path
}
const caseInsensitive = !explicitHome || /^[A-Za-z]:\//.test(home) || home.startsWith('/Users/')
if (!startsWithHome(path, home, caseInsensitive)) {
return path
}
if (path.length === home.length) {
return '~'
}
return `~${path.slice(home.length)}`
}
/** Last path segment for compact labels (statusbar leaf, settings rows). */
export function pathLeaf(raw: null | string | undefined): string {
const path = normalizeDisplayPath(raw || '')
if (!path || path === '/' || path === '~') {
return path
}
// `C:/` drive root
if (/^[A-Za-z]:\/$/.test(path) || /^[A-Za-z]:$/.test(path)) {
return path.endsWith('/') ? path : `${path}/`
}
const leaf = path.split('/').filter(Boolean).pop()
return leaf || path
}
+16
View File
@@ -0,0 +1,16 @@
/** Save generated text content to a file via a blob download. Electron's
* default will-download behavior shows the OS save dialog, so this works
* without a dedicated IPC handler (same pattern as use-image-download's
* browser fallback). */
export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') {
const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` }))
const link = document.createElement('a')
link.href = blobUrl
link.download = name
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
+44
View File
@@ -0,0 +1,44 @@
import { SLASH_COMMAND_RE } from './chat-runtime'
/** Matches `agent/title_generator.py`'s MAX_DERIVED_TITLE_CHARS, so a draft
* doesn't visibly reflow the moment the backend's derived title replaces it. */
const MAX_DRAFT_TITLE_CHARS = 48
/**
* Name a draft after what the user has typed into it.
*
* The client-side twin of the backend's `derive_title`: first meaningful line,
* whitespace collapsed, cut on a word boundary. It runs before any session
* exists, so it can't reach the real titler — a draft has no persisted row and
* no opening message yet, which is exactly what `apply_instant_title` needs.
*
* Empty when there's nothing worth naming, so the caller keeps "New session"
* rather than showing a title that says less than the placeholder.
*/
export function deriveDraftTitle(text: string): string {
const line =
text
.split('\n')
.find(candidate => candidate.trim())
?.trim() ?? ''
if (!line) {
return ''
}
// A bare `/skin` names the draft after the command rather than the work, the
// failure the backend titler summarizes away. Title from the argument instead;
// with no argument there is no intent yet, so the placeholder stands.
const body = (SLASH_COMMAND_RE.test(line) ? line.replace(/^\/\S+\s*/, '') : line).split(/\s+/).join(' ')
if (body.length <= MAX_DRAFT_TITLE_CHARS) {
return body
}
// Cut on a word boundary, unless that would throw away more than half of it.
const cut = body.slice(0, MAX_DRAFT_TITLE_CHARS)
const space = cut.lastIndexOf(' ')
const kept = space > MAX_DRAFT_TITLE_CHARS / 2 ? cut.slice(0, space) : cut
return `${kept.replace(/[\s,.;:—-]+$/, '')}…`
}
+43
View File
@@ -0,0 +1,43 @@
/**
* A flat, pointer-following drag chip — the shared "what am I holding"
* affordance for in-app pointer drags. Plain DOM (no React) so it survives a
* pointer-capture drag without re-renders and tears down synchronously on Esc.
*
* Flat by design: a solid app surface with the dragged item's label, no
* border / radius / shadow, dimmed — it copies the real row/tab it represents
* rather than reading as a separate pill. Any pointer drag whose source does
* not stay visibly "held" can reuse this (the drag primitive in
* `pane-shell/tree/renderer/drag-session.ts`, and anything built on it).
*/
/** How far (px) the chip trails the pointer so it never sits under the cursor. */
const OFFSET_X = 14
const OFFSET_Y = 12
export interface DragGhost {
/** Reposition the chip near the current pointer point. */
moveTo(x: number, y: number): void
/** Remove the chip from the DOM. Idempotent. */
destroy(): void
}
export function createDragGhost(label: string): DragGhost {
const el = document.createElement('div')
el.textContent = label
el.style.cssText =
'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' +
'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.625rem;opacity:0.6;' +
'background:var(--ui-sidebar-surface-background,var(--dt-card));color:var(--ui-text-primary);' +
'font-size:0.75rem;font-weight:500;will-change:transform'
document.body.appendChild(el)
return {
moveTo(x, y) {
el.style.transform = `translate3d(${x + OFFSET_X}px, ${y + OFFSET_Y}px, 0)`
},
destroy() {
el.remove()
}
}
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { extractEmbeddedImages, extractImageRefs } from './embedded-images'
const SAMPLE_PNG_DATA_URL = 'data:image/png;base64,' + 'A'.repeat(120)
describe('extractEmbeddedImages', () => {
it('returns text untouched when no data URL is present', () => {
expect(extractEmbeddedImages('describe this')).toEqual({ cleanedText: 'describe this', images: [] })
})
it('lifts a bare data:image URL out of prose', () => {
const result = extractEmbeddedImages(`describe this ${SAMPLE_PNG_DATA_URL}`)
expect(result.cleanedText).toBe('describe this')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
})
it('lifts a JSON-wrapped image_url envelope out of prose', () => {
const result = extractEmbeddedImages(
`describe this{"type":"image_url","image_url":{"url":"${SAMPLE_PNG_DATA_URL}"}}`
)
expect(result.cleanedText).toBe('describe this')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL])
})
it('extracts multiple embedded images', () => {
const second = 'data:image/jpeg;base64,' + 'B'.repeat(96)
const result = extractEmbeddedImages(`first ${SAMPLE_PNG_DATA_URL} mid ${second} tail`)
expect(result.cleanedText).toBe('first mid tail')
expect(result.images).toEqual([SAMPLE_PNG_DATA_URL, second])
})
it('handles multi-megabyte data URLs without overflowing the JS stack', () => {
const hugeDataUrl = 'data:image/png;base64,' + 'A'.repeat(8_000_000)
const result = extractEmbeddedImages(`describe this ${hugeDataUrl} thanks`)
expect(result.cleanedText).toBe('describe this thanks')
expect(result.images).toHaveLength(1)
expect(result.images[0]).toHaveLength(hugeDataUrl.length)
})
})
describe('extractImageRefs', () => {
it('returns the text untouched and no refs when there are no directives', () => {
expect(extractImageRefs('a normal prompt')).toEqual({ cleanedText: 'a normal prompt', refs: [] })
})
it('lifts leading @image directive lines into refs and clears the text', () => {
const result = extractImageRefs('@image:/tmp/cat.png\nwhat do you see?')
expect(result).toEqual({ cleanedText: 'what do you see?', refs: ['@image:/tmp/cat.png'] })
})
it('collects multiple refs in order', () => {
const result = extractImageRefs('@image:/tmp/a.png\n@image:/tmp/b.png\ncompare them')
expect(result.cleanedText).toBe('compare them')
expect(result.refs).toEqual(['@image:/tmp/a.png', '@image:/tmp/b.png'])
})
it('keeps only the directive lines when there is no trailing text', () => {
const result = extractImageRefs('@image:/tmp/only.png')
expect(result).toEqual({ cleanedText: '', refs: ['@image:/tmp/only.png'] })
})
it('lifts a backtick-quoted ref so a path with spaces survives intact', () => {
const ref = '@image:`/Users/me/Library/Application Support/Hermes/composer-images/a.png`'
const result = extractImageRefs(`${ref}\nwhat is this?`)
expect(result).toEqual({ cleanedText: 'what is this?', refs: [ref] })
})
it('drops the [screenshot] placeholder a native-vision turn leaves behind', () => {
// Flattening a parts list replaces each image part with `[screenshot]`; the
// lifted ref already renders that same attachment.
const result = extractImageRefs('@image:/tmp/cat.png\nwhat is in this photo?\n[screenshot]')
expect(result).toEqual({ cleanedText: 'what is in this photo?', refs: ['@image:/tmp/cat.png'] })
})
it('keeps [screenshot] when the message carries no image refs', () => {
expect(extractImageRefs('[screenshot]\nlook at the attached capture')).toEqual({
cleanedText: '[screenshot]\nlook at the attached capture',
refs: []
})
})
})
+204
View File
@@ -0,0 +1,204 @@
const DATA_URL_RE = /^data:([\w./+-]+);base64,(.*)$/i
const DATA_IMAGE_PREFIX = 'data:image/'
const BASE64_MARKER = ';base64,'
const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64
const JSON_IMAGE_OPEN_RE = /\{\s*"type"\s*:\s*"image_url"\s*,\s*"image_url"\s*:\s*\{\s*"url"\s*:\s*"$/
const JSON_IMAGE_CLOSE_RE = /^"\s*\}\s*\}/
const JSON_IMAGE_OPEN_MAX = 96
const JSON_IMAGE_CLOSE_MAX = 16
export const DATA_IMAGE_URL_RE = /^data:image\/[\w.+-]+;base64,/i
export interface EmbeddedImageExtraction {
cleanedText: string
images: string[]
}
export function dataUrlToBlob(dataUrl: string): Blob | null {
const match = DATA_URL_RE.exec(dataUrl.trim())
if (!match) {
return null
}
try {
const bytes = atob(match[2])
const buffer = new Uint8Array(bytes.length)
for (let i = 0; i < bytes.length; i += 1) {
buffer[i] = bytes.charCodeAt(i)
}
return new Blob([buffer], { type: match[1] })
} catch {
return null
}
}
function isImageMimeCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 43 ||
code === 45 ||
code === 46 ||
code === 95
)
}
function isBase64Code(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
code === 43 ||
code === 47 ||
code === 61
)
}
function readDataImageUrl(text: string, start: number): { end: number; url: string } | null {
if (!text.startsWith(DATA_IMAGE_PREFIX, start)) {
return null
}
let cursor = start + DATA_IMAGE_PREFIX.length
while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) {
return null
}
cursor += BASE64_MARKER.length
const base64Start = cursor
while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) {
return null
}
return { end: cursor, url: text.slice(start, cursor) }
}
function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: number): { end: number; start: number } {
let start = dataStart
let end = dataEnd
const openSearchStart = Math.max(0, dataStart - JSON_IMAGE_OPEN_MAX)
const openMatch = text.slice(openSearchStart, dataStart).match(JSON_IMAGE_OPEN_RE)
if (openMatch?.index !== undefined) {
const close = text.slice(dataEnd, dataEnd + JSON_IMAGE_CLOSE_MAX).match(JSON_IMAGE_CLOSE_RE)
if (close) {
start = openSearchStart + openMatch.index
end = dataEnd + close[0].length
}
}
return { end, start }
}
function normalizeCleanedText(text: string): string {
return text
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
return { cleanedText: text, images: [] }
}
const images: string[] = []
const pieces: string[] = []
let appendCursor = 0
let searchCursor = 0
while (searchCursor < text.length) {
const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor)
if (dataStart === -1) {
break
}
const dataUrl = readDataImageUrl(text, dataStart)
if (!dataUrl) {
searchCursor = dataStart + DATA_IMAGE_PREFIX.length
continue
}
const range = embeddedImageRemovalRange(text, dataStart, dataUrl.end)
pieces.push(text.slice(appendCursor, range.start))
images.push(dataUrl.url)
appendCursor = range.end
searchCursor = range.end
}
if (!images.length) {
return { cleanedText: text, images: [] }
}
pieces.push(text.slice(appendCursor))
return { cleanedText: normalizeCleanedText(pieces.join('')), images }
}
export function embeddedImageUrls(text: string): string[] {
return extractEmbeddedImages(text).images
}
export function textWithoutEmbeddedImages(text: string): string {
return extractEmbeddedImages(text).cleanedText
}
// The gateway persists attached images as `@image:<path>` directive lines
// (see tui_gateway/server.py's persist-time rewrite), prepended before the
// user's own text. The composer's own optimistic/local turn never carries
// this prefix — it keeps the attachment as separate `attachmentRefs`
// metadata, not inline text. The turn-equality comparisons in
// preserveLocalPendingTurnMessages / appendLiveSessionProjection strip ALL
// reference-directive lines (not just images) via
// `textWithoutReferenceLines` in components/assistant-ui/reference-kinds.ts;
// IMAGE_REF_LINE_RE remains here for extractImageRefs below, which moves the
// image directives into attachmentRefs metadata.
const IMAGE_REF_LINE_RE = /^@image:[^\n]*\n?/gm
// Same directive lines as IMAGE_REF_LINE_RE, but keeps them instead of
// discarding — used when converting persisted server messages into
// ChatMessage/ThreadMessageLike shape, where `@image:<path>` refs need to
// move from inline text into the `attachmentRefs` metadata field (mirroring
// how the local optimistic composer represents attachments) rather than stay
// embedded in the bubble's clamped text body, where a large inline thumbnail
// pushes the caption text out of the clamp's visible area.
// Native-vision turns are stored as a parts list, which the session store
// flattens by replacing each image part with a literal `[screenshot]` line. The
// `@image:` ref describes that same attachment, so keeping both renders the
// placeholder as stray text under the thumbnail. Drop it only when a ref was
// actually lifted, so a `[screenshot]` in a message without attachments stays.
const SCREENSHOT_PLACEHOLDER_LINE_RE = /^\[screenshot\]\n?/gm
export function extractImageRefs(text: string): { cleanedText: string; refs: string[] } {
const refs: string[] = []
let cleanedText = text.replace(IMAGE_REF_LINE_RE, match => {
refs.push(match.trim())
return ''
})
if (refs.length) {
cleanedText = cleanedText.replace(SCREENSHOT_PLACEHOLDER_LINE_RE, '')
}
return { cleanedText: cleanedText.trim(), refs }
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { formatErrorDiagnostics, parseErrorSurface } from './error-surface'
describe('parseErrorSurface', () => {
it('accepts a valid descriptor', () => {
expect(parseErrorSurface({ layer: 'streaming', code: 'stream_drop', retryable: true })).toEqual({
layer: 'streaming',
code: 'stream_drop',
retryable: true
})
})
it('accepts every documented layer', () => {
for (const layer of ['provider', 'endpoint', 'streaming', 'auth', 'billing', 'gateway', 'runtime', 'disk']) {
expect(parseErrorSurface({ layer, code: 'x', retryable: false })?.layer).toBe(layer)
}
})
it('rejects unknown layers and non-objects', () => {
expect(parseErrorSurface({ layer: 'blockchain', code: 'x', retryable: true })).toBeNull()
expect(parseErrorSurface('provider')).toBeNull()
expect(parseErrorSurface(null)).toBeNull()
expect(parseErrorSurface(undefined)).toBeNull()
expect(parseErrorSurface(7)).toBeNull()
})
it('defaults code and retryable when missing', () => {
expect(parseErrorSurface({ layer: 'gateway' })).toEqual({ layer: 'gateway', code: 'unknown', retryable: true })
})
it('honors retryable=false', () => {
expect(parseErrorSurface({ layer: 'auth', code: 'auth_permanent', retryable: false })?.retryable).toBe(false)
})
it('carries the failing session identity when present', () => {
const surface = parseErrorSurface({
layer: 'provider',
code: 'rate_limit',
retryable: true,
provider: 'openrouter',
model: 'test/m1'
})
expect(surface?.provider).toBe('openrouter')
expect(surface?.model).toBe('test/m1')
// Absent identity yields no keys, not empty strings.
expect(parseErrorSurface({ layer: 'provider', code: 'x', retryable: true })?.provider).toBeUndefined()
})
})
describe('formatErrorDiagnostics', () => {
it('includes layer, code, model and error', () => {
const text = formatErrorDiagnostics({
errorText: 'boom',
model: 'anthropic/claude-opus-4.6',
surface: { layer: 'provider', code: 'rate_limit', retryable: true }
})
expect(text).toContain('layer: provider')
expect(text).toContain('code: rate_limit')
expect(text).toContain('model: anthropic/claude-opus-4.6')
expect(text).toContain('error: boom')
})
it('prefers the descriptor identity over the caller fallback', () => {
const text = formatErrorDiagnostics({
errorText: 'boom',
// Foreground composer atom — potentially stale by click time.
model: 'some/other-model',
surface: { layer: 'provider', code: 'rate_limit', retryable: true, provider: 'openrouter', model: 'failed/model' }
})
expect(text).toContain('provider: openrouter')
expect(text).toContain('model: failed/model')
expect(text).not.toContain('some/other-model')
})
it('omits absent fields without leaving blank lines', () => {
const text = formatErrorDiagnostics({ errorText: 'boom' })
expect(text).not.toContain('layer:')
expect(text).not.toContain('model:')
expect(text.split('\n').every(line => line.trim().length > 0)).toBe(true)
})
})
+83
View File
@@ -0,0 +1,83 @@
// Structured turn-error descriptor forwarded by the gateway (see
// agent/error_surface.py). Names WHICH layer of the stack failed so the error
// card can say "Provider error" / "Gateway error" and offer layer-appropriate
// recovery actions, instead of toasting an opaque string.
//
// Advisory contract: older backends never send this — every consumer must
// keep working when it is absent (legacy string-sniffing stays as fallback).
export const ERROR_SURFACE_LAYERS = [
'provider',
'endpoint',
'streaming',
'auth',
'billing',
'gateway',
'runtime',
'disk'
] as const
export type ErrorSurfaceLayer = (typeof ERROR_SURFACE_LAYERS)[number]
export interface ErrorSurface {
layer: ErrorSurfaceLayer
/** Specific failure code (a FailoverReason value or site-specific code). */
code: string
/** False when retrying unchanged reproduces the same failure. */
retryable: boolean
/** The failing session's provider/model, captured at classification time —
* preferred over the foreground composer's atoms, which can point at a
* different model by the time the user clicks an action. */
provider?: string
model?: string
}
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
export function parseErrorSurface(value: unknown): ErrorSurface | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as { code?: unknown; layer?: unknown; model?: unknown; provider?: unknown; retryable?: unknown }
const layer = typeof raw.layer === 'string' ? (raw.layer as ErrorSurfaceLayer) : null
if (!layer || !ERROR_SURFACE_LAYERS.includes(layer)) {
return null
}
return {
layer,
code: typeof raw.code === 'string' && raw.code ? raw.code : 'unknown',
retryable: raw.retryable !== false,
...(typeof raw.provider === 'string' && raw.provider ? { provider: raw.provider } : {}),
...(typeof raw.model === 'string' && raw.model ? { model: raw.model } : {})
}
}
/** Plain-text error-details blob for the error card's "Copy error details". */
export function formatErrorDiagnostics(input: {
appVersion?: string
errorText: string
model?: string
provider?: string
surface?: ErrorSurface | null
}): string {
// The descriptor's identity (captured when the turn failed) beats the
// caller-supplied fallback (typically the foreground composer's atoms).
const provider = input.surface?.provider || input.provider
const model = input.surface?.model || input.model
const lines = [
'── Hermes error details ──',
`time: ${new Date().toISOString()}`,
input.surface ? `layer: ${input.surface.layer}` : null,
input.surface ? `code: ${input.surface.code}` : null,
input.surface ? `retryable: ${input.surface.retryable}` : null,
provider ? `provider: ${provider}` : null,
model ? `model: ${model}` : null,
input.appVersion ? `app: ${input.appVersion}` : null,
`error: ${input.errorText}`
]
return lines.filter((line): line is string => Boolean(line)).join('\n')
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from './escape-layers'
describe('escape-layers', () => {
it('reports top when nothing is registered', () => {
expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true)
})
it('a lower layer yields to a higher open one, and reclaims top when it closes', () => {
const releaseOverlay = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
// Narrow overlay is open under a full-page overlay — it must not act.
expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(false)
// The overlay itself is top.
expect(isTopEscapeLayer(ESCAPE_PRIORITY.overlay)).toBe(true)
releaseOverlay()
expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true)
})
it('equal-or-higher priority counts as top (ties act)', () => {
const release = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor)
expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true)
expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false)
release()
})
it('tracks the max across several open layers', () => {
const releases = [
pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay),
pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit),
pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor)
]
expect(isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)).toBe(true)
expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(false)
// Close the zone editor — layout edit becomes top.
releases[2]()
expect(isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)).toBe(true)
releases.forEach(release => release())
expect(isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)).toBe(true)
})
})
+53
View File
@@ -0,0 +1,53 @@
/**
* Ordered Escape ownership for the app's transient window-level layers.
*
* Several surfaces bind their own `window` `keydown` Escape handler (narrow-pane
* reveal, layout edit mode, the zone editor, full-page overlays). Without a
* shared order a single Escape fired all of them at once — closing a pinned
* pane *and* exiting edit mode, or dismissing an overlay *and* the pane beneath
* it. Radix dialogs already stop propagation / preventDefault, so this is only
* about the app's own handlers.
*
* Contract for a layer handler:
* 1. bail if `event.defaultPrevented` (a higher, propagation-stopping layer
* — a Radix dialog — already handled it);
* 2. bail unless `isTopEscapeLayer(myPriority)` (a higher app layer is open);
* 3. otherwise act and `event.preventDefault()`.
*
* A layer registers its priority (via `pushEscapeLayer`) only while it's open.
*/
// Higher number = closer to the user. Gaps leave room to slot new layers.
export const ESCAPE_PRIORITY = {
narrowOverlay: 10,
layoutEdit: 20,
zoneEditor: 30,
overlay: 40,
// An in-flight pane drag: Esc means "abort the drag", never ALSO exit edit
// mode / close the overlay the drag started over. Registered only for the
// drag's few-hundred-ms lifetime (drag-session.ts).
drag: 50
} as const
const active = new Map<symbol, number>()
/** Register a layer as open; call the returned disposer when it closes. */
export function pushEscapeLayer(priority: number): () => void {
const key = Symbol('escape-layer')
active.set(key, priority)
return () => {
active.delete(key)
}
}
/** True when no open layer outranks `priority`, so its handler should act. */
export function isTopEscapeLayer(priority: number): boolean {
for (const p of active.values()) {
if (p > priority) {
return false
}
}
return true
}
+44
View File
@@ -0,0 +1,44 @@
// Always hidden across the file tree and review (git) tree, regardless of
// .gitignore: the VCS internals, heavyweight dep/build/cache dirs, and OS noise.
// These bloat both trees and are never worth browsing or reviewing — even in
// repos that track them, and in plain non-git folders.
export const ALWAYS_EXCLUDED = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'bower_components',
'.venv',
'venv',
'env',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'.tox',
'.gradle',
'.idea',
'dist',
'build',
'out',
'target',
'vendor',
'Pods',
'.next',
'.nuxt',
'.svelte-kit',
'.output',
'.turbo',
'.parcel-cache',
'.cache',
'.terraform',
'.expo',
'.angular',
'coverage',
'.DS_Store',
'Thumbs.db'
])
// True when any segment of a relative path is excluded (review rows like
// `node_modules/.bin/foo` or a bare `.DS_Store`). Handles `/` and `\`.
export const isExcludedPath = (relPath: string): boolean => relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg))
+359
View File
@@ -0,0 +1,359 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { IS_MAC } from '@/lib/keybinds/combo'
import { $previewTabs, closeRightRail } from '@/store/preview'
import {
__resetLinkTitleCache,
ExternalLink,
fetchLinkTitle,
hostPathLabel,
hudForcesNativeLinks,
isTitleFetchable,
LinkifiedText,
MarkdownLinkText,
PrettyLink,
urlSlugTitleLabel
} from './external-link'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
function installDesktopBridge(partial: Partial<Window['hermesDesktop']> = {}) {
desktopWindow.hermesDesktop = {
fetchLinkTitle: vi.fn().mockResolvedValue(''),
openExternal: vi.fn().mockResolvedValue(undefined),
...partial
} as unknown as Window['hermesDesktop']
}
const FORGEJO_URL = 'https://forgejo.home.example/homelab/homelab-ops/issues/101'
function installTitleBridge(title: string) {
const bridge = vi.fn().mockResolvedValue(title)
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
return bridge
}
afterEach(() => {
__resetLinkTitleCache()
closeRightRail()
vi.restoreAllMocks()
cleanup()
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('external link helpers', () => {
it('formats URL fallbacks as host + path', () => {
expect(
hostPathLabel(
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
)
).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
})
it('derives readable title fallbacks from URL slugs', () => {
expect(
urlSlugTitleLabel(
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
)
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
it('filters out local/non-http targets for title fetches', () => {
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
expect(isTitleFetchable('http://localhost:5174')).toBe(false)
expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
})
it('deduplicates in-flight title fetches and caches results', async () => {
const bridge = vi.fn().mockResolvedValue('El Yunque Tour Water Slide, Rope Swing & Pickup')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url =
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure-with-transport.a46272756.activity-details'
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(bridge).toHaveBeenCalledTimes(1)
const third = await fetchLinkTitle(url)
expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
expect(bridge).toHaveBeenCalledTimes(1)
})
it('shares cache across protocol/www URL variants', async () => {
const bridge = vi.fn().mockResolvedValue('Shared Canonical Title')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
expect(a).toBe('Shared Canonical Title')
expect(b).toBe('Shared Canonical Title')
expect(bridge).toHaveBeenCalledTimes(1)
})
// A web link belongs in the in-app browser now; the OS browser is the
// ⌘/Ctrl-click escape hatch.
it('opens a web link in the in-app browser', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
expect(openExternal).not.toHaveBeenCalled()
await waitFor(() => expect($previewTabs.get().at(-1)?.target.url).toBe('https://example.com/path/to/resource'))
})
// Platform-specific on purpose (same rule as terminal links / middle-click):
// ⌘ on macOS, Ctrl elsewhere. The suite runs as non-mac.
it('escapes to the OS browser on the platform open-elsewhere modifier', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Example link' }), IS_MAC ? { metaKey: true } : { ctrlKey: true })
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
expect($previewTabs.get()).toHaveLength(0)
})
it('treats only the HUD renderer as a native-link surface', () => {
expect(hudForcesNativeLinks('')).toBe(false)
expect(hudForcesNativeLinks('?win=secondary')).toBe(false)
expect(hudForcesNativeLinks('?win=browser&tab=1')).toBe(false)
expect(hudForcesNativeLinks('?win=hud')).toBe(true)
expect(hudForcesNativeLinks('?profile=work&win=hud')).toBe(true)
})
// The HUD has no in-app browser. A click that opened a preview tile would
// try to paint a webview into the transparent overlay (OAuth, consoles).
it('sends every HUD web link to the OS browser', () => {
const originalLocation = window.location
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, search: '?win=hud' }
})
try {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(<ExternalLink href="https://accounts.google.com/o/oauth2/auth">Sign in</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Sign in' }))
expect(openExternal).toHaveBeenCalledWith('https://accounts.google.com/o/oauth2/auth')
expect($previewTabs.get()).toHaveLength(0)
} finally {
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
}
})
// A setup step sends you to a console you are signed into in your own
// browser, to fill in a form and copy a secret back. The in-app pane has
// none of that session and is the wrong destination even for web URLs.
it('sends a setup-step link straight to the OS browser', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(<MarkdownLinkText text="Enable the [Docs API](https://console.cloud.google.com/apis/library) first." />)
fireEvent.click(screen.getByRole('link', { name: 'Docs API' }))
expect(openExternal).toHaveBeenCalledWith('https://console.cloud.google.com/apis/library')
expect($previewTabs.get()).toHaveLength(0)
})
// A webview can't do anything useful with these, so they always hand off.
it('hands a non-web scheme to the OS', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
render(<ExternalLink href="mailto:hi@example.com">Mail</ExternalLink>)
fireEvent.click(screen.getByRole('link', { name: 'Mail' }))
expect(openExternal).toHaveBeenCalledWith('mailto:hi@example.com')
})
it('hides the trailing external-link icon by default', () => {
installDesktopBridge()
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeNull()
})
it('shows a trailing external-link icon when opted in', () => {
installDesktopBridge()
render(
<ExternalLink href="https://example.com/path/to/resource" showExternalIcon>
Example link
</ExternalLink>
)
const link = screen.getByRole('link', { name: 'Example link' })
expect(link.querySelector('svg')).toBeTruthy()
})
it('renders pretty links with fetched titles and no host suffix', async () => {
const bridge = vi.fn().mockResolvedValue('From Fajardo: Full-Day Culebra Islands Catamaran Tour')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(<LinkifiedText text={`Read ${url}`} />)
const link = screen.getByTitle(url)
expect(link.textContent).toContain('From Fajardo Full Day Cordillera Islands Catamaran Tour')
await waitFor(() => {
expect(link.textContent).toContain('From Fajardo: Full-Day Culebra Islands Catamaran Tour')
})
expect(link.textContent).not.toContain('getyourguide.com')
})
it('shows host/path fallback when title is unavailable', () => {
installDesktopBridge()
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque'
render(<PrettyLink href={url} />)
const link = screen.getByTitle(url)
expect(link.textContent).toBe('Puerto Rico El Yunque')
})
it('ignores error-like fetched titles and falls back to slug label', async () => {
const bridge = vi.fn().mockResolvedValue('GetYourGuide – Error')
installDesktopBridge({ fetchLinkTitle: bridge as unknown as Window['hermesDesktop']['fetchLinkTitle'] })
const url =
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
render(<PrettyLink href={url} />)
const link = screen.getByTitle(url)
await waitFor(() => {
expect(link.textContent).toBe('From Fajardo Full Day Cordillera Islands Catamaran Tour')
})
})
it('treats not-found fetched titles as unusable', async () => {
const bridge = installTitleBridge('Page not found - Forgejo')
await expect(fetchLinkTitle(FORGEJO_URL)).resolves.toBe('')
expect(bridge).toHaveBeenCalledTimes(1)
})
it('keeps an authored fallbackLabel ahead of a fetched title, and skips the fetch', async () => {
const bridge = installTitleBridge('Kinkolino Forgejo')
// Chat markdown passes authored link text as `fallbackLabel`, not `label`.
render(<PrettyLink fallbackLabel="FJ #101" href={FORGEJO_URL} />)
const link = screen.getByTitle(FORGEJO_URL)
await waitFor(() => {
expect(link.textContent).toContain('FJ #101')
})
expect(link.textContent).not.toContain('Kinkolino Forgejo')
expect(bridge).not.toHaveBeenCalled()
})
it('still resolves a title when no label was authored', async () => {
const bridge = installTitleBridge('Homelab Ops Issue 101')
render(<PrettyLink href={FORGEJO_URL} />)
await waitFor(() => {
expect(screen.getByTitle(FORGEJO_URL).textContent).toContain('Homelab Ops Issue 101')
})
expect(bridge).toHaveBeenCalledTimes(1)
})
it('normalizes scheme-less links before opening', () => {
installDesktopBridge()
render(<LinkifiedText text="Source expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure" />)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe(
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
)
})
it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => {
installDesktopBridge()
render(
<LinkifiedText
explicitOnly
pretty={false}
text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'}
/>
)
const links = screen.getAllByRole('link')
expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def'])
// Bare filename-shaped tokens stay as plain text, not links.
expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy()
expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false)
})
it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => {
installDesktopBridge()
render(<LinkifiedText pretty={false} text="open agent.log please" />)
const link = screen.getByRole('link', { name: 'agent.log' })
expect(link.getAttribute('href')).toBe('https://agent.log')
})
it('prefixes a pretty link to a known host with its brand glyph', () => {
installDesktopBridge()
const url = 'https://github.com/NousResearch/hermes-agent/pull/123'
render(<PrettyLink fallbackLabel="#123" href={url} />)
const link = screen.getByTitle(url)
expect(link.querySelector('svg')).toBeTruthy()
// The glyph is decorative — it must not pollute the link's accessible name.
expect(link.textContent).toBe('#123')
})
it('renders no brand glyph for an unknown host', () => {
installDesktopBridge()
const url = 'https://example.com/some/page'
render(<PrettyLink fallbackLabel="Some Page" href={url} />)
expect(screen.getByTitle(url).querySelector('svg')).toBeNull()
})
})
+449
View File
@@ -0,0 +1,449 @@
import type { ComponentProps, ReactNode } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ArrowUpRight } from '@/lib/icons'
import { IS_MAC } from '@/lib/keybinds/combo'
import { resolveBrandIcon } from './brand-icon'
import { cn } from './utils'
const titleCache = new Map<string, string>()
const titleInflight = new Map<string, Promise<string>>()
const titleSubs = new Map<string, Set<(value: string) => void>>()
const URL_RE =
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
// otherwise mistake for domains and linkify.
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
const ERROR_TITLE_RE =
/\b(?:access denied|attention required|captcha|error|forbidden|just a moment|not found|request blocked|too many requests)\b/i
export function normalizeExternalUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed || /^https?:\/\//i.test(trimmed)) {
return trimmed
}
return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed
}
function parseUrl(value: string): null | URL {
try {
return new URL(normalizeExternalUrl(value))
} catch {
return null
}
}
function titleCacheKey(value: string): string {
const url = parseUrl(value)
if (!url) {
return normalizeExternalUrl(value)
}
const host = url.hostname.replace(/^www\./i, '').toLowerCase()
const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
return `${host}${pathname}${url.search || ''}`
}
export function shortHostLabel(value: string): string {
return parseUrl(value)?.hostname.replace(/^www\./, '') ?? value
}
export function hostPathLabel(value: string): string {
const url = parseUrl(value)
if (!url) {
return value
}
const host = url.hostname.replace(/^www\./, '')
const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''
return `${host}${path}`
}
function cleanSlug(segment: string): string {
try {
return decodeURIComponent(segment)
.replace(/\.a\d+\..*$/i, '')
.replace(/\.(?:html?|php|aspx?)$/i, '')
.replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
} catch {
return ''
}
}
export function urlSlugTitleLabel(value: string): string {
const url = parseUrl(value)
for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) {
const cleaned = cleanSlug(segment)
if (!cleaned || !/[a-z]/i.test(cleaned)) {
continue
}
if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) {
continue
}
const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
if (titled.length >= 4) {
return titled
}
}
return hostPathLabel(value)
}
export function isTitleFetchable(value: string): boolean {
if (!value || SKIP_PROTO_RE.test(value)) {
return false
}
const url = parseUrl(value)
return Boolean(url && /^https?:$/.test(url.protocol) && !LOCAL_HOST_RE.test(url.host))
}
export function fetchLinkTitle(url: string): Promise<string> {
const normalizedUrl = normalizeExternalUrl(url)
const key = titleCacheKey(normalizedUrl)
if (!isTitleFetchable(normalizedUrl)) {
return Promise.resolve('')
}
if (titleCache.has(key)) {
return Promise.resolve(titleCache.get(key) ?? '')
}
const pending = titleInflight.get(key)
if (pending) {
return pending
}
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop?.fetchLinkTitle
if (!bridge) {
titleCache.set(key, '')
return Promise.resolve('')
}
const promise = bridge(normalizedUrl)
.then(value => (value || '').replace(/\s+/g, ' ').trim())
.then(clean => (clean && !ERROR_TITLE_RE.test(clean) ? clean : ''))
.catch(() => '')
.then(safe => {
titleCache.set(key, safe)
titleInflight.delete(key)
titleSubs.get(key)?.forEach(sub => sub(safe))
return safe
})
titleInflight.set(key, promise)
return promise
}
export function useLinkTitle(url?: null | string): string {
const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url])
const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl])
const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : ''))
useEffect(() => {
setTitle(key ? (titleCache.get(key) ?? '') : '')
if (!key || !isTitleFetchable(normalizedUrl)) {
return
}
const subs = titleSubs.get(key) ?? new Set<(value: string) => void>()
subs.add(setTitle)
titleSubs.set(key, subs)
void fetchLinkTitle(normalizedUrl)
return () => {
subs.delete(setTitle)
if (!subs.size) {
titleSubs.delete(key)
}
}
}, [key, normalizedUrl])
return title
}
export function openExternalLink(href: string): void {
if (href) {
void window.hermesDesktop?.openExternal?.(href)
}
}
/**
* True when a click asked for the SYSTEM browser — ⌘ on macOS, Ctrl elsewhere,
* the modifier every app uses for "open this somewhere else". Middle-click
* counts too: it is the other half of the same convention.
*/
export function wantsNativeBrowser(event: Pick<MouseEvent, 'button' | 'ctrlKey' | 'metaKey'>): boolean {
return event.button === 1 || (IS_MAC ? event.metaKey : event.ctrlKey)
}
/**
* The HUD is a chrome-free bar with no in-app browser. A preview tile there
* either no-ops or tries to paint a webview into the transparent overlay —
* the OAuth-in-the-HUD case. Always hand off to the OS browser.
*/
export function hudForcesNativeLinks(search = typeof window === 'undefined' ? '' : window.location.search): boolean {
try {
return new URLSearchParams(search).get('win') === 'hud'
} catch {
return false
}
}
/**
* Where a link the user clicked should open.
*
* A web page opens in the in-app browser — that pane exists so reading a doc
* doesn't cost a context switch out of Hermes, and it is the surface the agent
* can see. ⌘/Ctrl-click (or middle-click) escapes to the real browser, which is
* where you go for anything needing your logged-in session or a password.
*
* Everything that ISN'T a web page — `mailto:`, `file:`, a custom scheme — has
* no business in the webview and always hands off to the OS. The HUD has no
* browser pane, so it always takes the OS path.
*/
export function openLink(href: string, options: { native?: boolean } = {}): void {
const target = normalizeExternalUrl(href)
if (!target) {
return
}
if (options.native || hudForcesNativeLinks() || !/^https?:$/i.test(parseUrl(target)?.protocol ?? '')) {
openExternalLink(target)
return
}
// Lazy: this module is a leaf every surface imports, and the preview store
// pulls the layout/session graph behind it. A static edge would make one
// link helper drag that whole tree into anything that renders a link. The
// tab lands a microtask later, which is invisible.
void import('@/store/preview').then(({ openPreview }) =>
openPreview({ kind: 'url', label: hostPathLabel(target), source: target, url: target }, 'explicit-link')
)
}
interface ExternalLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
href: string
children?: ReactNode
/** Skip the in-app pane. For links whose whole point is the session you are
* signed into over there — a cloud console, an account page. */
native?: boolean
showExternalIcon?: boolean
}
export function ExternalLinkIcon({ className }: { className?: string }) {
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
}
// Brand mark for a known host, sized in `em` so it tracks the surrounding text
// at any font size. It paints in `currentColor` rather than the brand hex —
// several brand colors (GitHub's near-black, Unity's white) vanish against one
// theme or the other.
//
// `title=""` is load-bearing: Simple Icons always renders a <title> defaulting
// to the brand name, which lands in the anchor's textContent and accessible
// name — a PR link would read "GitHub#123".
export function LinkBrandIcon({ className, href }: { className?: string; href: string }) {
const Icon = resolveBrandIcon(shortHostLabel(href))
return Icon ? (
<Icon aria-hidden className={cn('mr-1 inline size-[0.85em] align-[-0.12em] opacity-80', className)} title="" />
) : null
}
export function ExternalLink({
children,
className,
href,
native = false,
onClick,
showExternalIcon = false,
...rest
}: ExternalLinkProps) {
const target = normalizeExternalUrl(href)
// No menu wiring here: the app context-menu coordinator resolves a
// right-click on any `a[href]` to the link menu (open in-app / open
// external / copy URL / copy resolved URL).
return (
<a
className={cn('ref', className)}
href={target}
// Middle-click never fires `click`; it's the other half of the
// open-elsewhere convention, so it has to be caught on its own.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
event.stopPropagation()
openExternalLink(target)
}}
onClick={event => {
event.stopPropagation()
onClick?.(event)
if (event.defaultPrevented) {
return
}
event.preventDefault()
openLink(target, { native: native || wantsNativeBrowser(event.nativeEvent) })
}}
rel="noopener noreferrer"
target="_blank"
{...rest}
>
{children ?? urlSlugTitleLabel(target)}
{showExternalIcon && <ExternalLinkIcon />}
</a>
)
}
interface PrettyLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'target'> {
href: string
label?: string
fallbackLabel?: string
}
// Title resolution is a fallback, not an override. Both props carry authored
// text — chat markdown passes `fallbackLabel` — so either one skips the fetch.
export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: PrettyLinkProps) {
const target = useMemo(() => normalizeExternalUrl(href), [href])
const authoredLabel = label?.trim() || fallbackLabel?.trim()
const fetched = useLinkTitle(authoredLabel ? null : target)
const display = authoredLabel || fetched || urlSlugTitleLabel(target)
return (
<ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}>
<LinkBrandIcon href={target} />
{display}
</ExternalLink>
)
}
interface LinkifiedTextProps {
className?: string
text: string
pretty?: boolean
explicitOnly?: boolean
}
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
const raw = match[0]
const url = normalizeExternalUrl(raw)
const index = match.index ?? 0
if (index > cursor) {
nodes.push(text.slice(cursor, index))
}
nodes.push(
pretty ? (
<PrettyLink href={url} key={`${url}-${index}`} />
) : (
<ExternalLink href={url} key={`${url}-${index}`}>
{raw}
</ExternalLink>
)
)
cursor = index + raw.length
}
if (cursor < text.length) {
nodes.push(text.slice(cursor))
}
return <span className={className}>{nodes.length ? nodes : text}</span>
}
const MD_LINK_RE = /\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g
/**
* Inline `[label](url)` and nothing else.
*
* For short authored strings — a catalog entry's setup steps — where the
* label carries the meaning ("enable the Docs API") and the URL is a console
* page whose own title is useless or, behind a login wall, actively wrong.
* `LinkifiedText` can't serve this: it finds bare URLs and guesses a label.
* Full markdown is the other extreme, a block renderer inside a card row.
*
* These open in the real browser. The destination is a console the user is
* already signed into there, and the work is a form to fill in and a secret to
* copy back — none of which the in-app pane is for.
*/
export function MarkdownLinkText({ className, text }: { className?: string; text: string }) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(MD_LINK_RE)) {
const [raw, label, href] = match
const index = match.index ?? 0
if (index > cursor) {
nodes.push(text.slice(cursor, index))
}
nodes.push(
<ExternalLink href={href} key={`${href}-${index}`} native title={href}>
{label}
</ExternalLink>
)
cursor = index + raw.length
}
if (cursor < text.length) {
nodes.push(text.slice(cursor))
}
return <span className={className}>{nodes.length ? nodes : text}</span>
}
export function __resetLinkTitleCache(): void {
titleCache.clear()
titleInflight.clear()
titleSubs.clear()
}
@@ -0,0 +1,64 @@
import { render, waitFor } from '@testing-library/react'
import { Streamdown } from 'streamdown'
import { describe, expect, it } from 'vitest'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess'
// This is the exact pipeline the file preview runs: normalizeFilePreviewMath
// → Streamdown with the memoized math plugin. Rendering here (jsdom) proves the
// wiring produces KaTeX output, not raw `$..$` source text.
describe('file-preview math rendering', () => {
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
it('renders inline $..$ math as KaTeX', async () => {
const { container } = render(
<Streamdown mode="static" plugins={{ math: mathPlugin }}>
{normalizeFilePreviewMath('The formula $x^2 + y^2$ here.')}
</Streamdown>
)
await waitFor(() => {
expect(container.querySelector('.katex')).not.toBeNull()
})
expect(container.textContent).not.toContain('$x^2')
})
it('renders $$..$$ display math as KaTeX', async () => {
const { container } = render(
<Streamdown mode="static" plugins={{ math: mathPlugin }}>
{normalizeFilePreviewMath('Block:\n\n$$E = mc^2$$')}
</Streamdown>
)
await waitFor(() => {
expect(container.querySelector('.katex-display') || container.querySelector('.katex')).not.toBeNull()
})
})
it('renders delimited math as KaTeX', async () => {
const { container } = render(
<Streamdown mode="static" plugins={{ math: mathPlugin }}>
{normalizeFilePreviewMath('A fraction \\(\\frac{a}{b}\\) inline.')}
</Streamdown>
)
await waitFor(() => {
expect(container.querySelector('.katex')).not.toBeNull()
})
expect(container.querySelector('.katex-mathml, .katex-html')).not.toBeNull()
})
it('leaves a literal code-fence $ alone as code, not math', async () => {
const { container } = render(
<Streamdown mode="static" plugins={{ math: mathPlugin }}>
{normalizeFilePreviewMath('```bash\necho $HOME\n```')}
</Streamdown>
)
await waitFor(() => {
expect(container.querySelector('.katex')).toBeNull()
})
expect(container.querySelector('code')?.textContent).toContain('$HOME')
})
})
@@ -0,0 +1,468 @@
/**
* Unit tests for the renderer-side find walker.
*
* These tests plant DOM by hand and drive the walker directly so the
* behavior is auditable without spinning up the React store. The end-to-
* end behavior (open / setFindQuery / step) is covered by find-bar.test.tsx.
*/
import { afterEach, describe, expect, it } from 'vitest'
import {
captureFindScope,
currentFindScope,
performScopedFind,
releaseFindScope,
resolveCurrentFindScope
} from './find-in-page-scope'
function plantSurface(id: string, html: string, hidden = false): HTMLElement {
const root = document.createElement('div')
root.id = id
root.setAttribute('data-chat-surface', '')
if (hidden) {
root.setAttribute('data-pane-hidden', '')
}
root.innerHTML = html
document.body.appendChild(root)
return root
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('resolveCurrentFindScope', () => {
it('returns the foreground chat surface and skips hidden ones', () => {
plantSurface('background', 'hidden chat', true)
const foreground = plantSurface('foreground', 'visible chat')
expect(resolveCurrentFindScope()?.id).toBe('foreground')
// Marking it lets currentFindScope find it without re-resolving.
captureFindScope()
expect(currentFindScope()?.id).toBe('foreground')
})
it('returns null when no chat surface is mounted', () => {
expect(resolveCurrentFindScope()).toBeNull()
})
it('returns the FIRST visible chat surface in document order', () => {
// queryVisible follows document order — the first matching surface
// wins. Visibility (hidden panes are skipped) is the only filter.
plantSurface('first', 'one')
plantSurface('second', 'two')
expect(resolveCurrentFindScope()?.id).toBe('first')
})
})
describe('performScopedFind', () => {
it('wraps every (case-insensitive) match in a <mark> and counts them', () => {
const surface = plantSurface('surface', '<p>NEEDLE needle Needle</p>')
const result = performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(3)
expect(result.activeOrdinal).toBe(1)
expect(surface.querySelectorAll('mark.find-hit').length).toBe(3)
// The first <mark> carries the active marker.
expect(surface.querySelectorAll('mark.find-hit[data-find-active]').length).toBe(1)
})
it('returns zero counts when the query has no matches and leaves the DOM untouched', () => {
const surface = plantSurface('surface', '<p>hello world</p>')
const result = performScopedFind(surface, 'nothing', { forward: true, findNext: false })
expect(result).toEqual({ count: 0, activeOrdinal: 0 })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(0)
expect(surface.querySelector('p')?.textContent).toBe('hello world')
})
it('clears highlights and zeroes the counter when the query is empty', () => {
const surface = plantSurface('surface', '<p>needle in a haystack</p>')
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
const result = performScopedFind(surface, '', { forward: true, findNext: false })
expect(result).toEqual({ count: 0, activeOrdinal: 0 })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(0)
// Original text restored.
expect(surface.querySelector('p')?.textContent).toBe('needle in a haystack')
})
it('advances the active mark on findNext and walks back on findPrevious', () => {
const surface = plantSurface('surface', '<p>a a a a</p>')
performScopedFind(surface, 'a', { forward: true, findNext: false })
const first = surface.querySelectorAll('mark.find-hit')
expect(first.length).toBe(4)
expect(first[0].hasAttribute('data-find-active')).toBe(true)
// Step forward twice; the active ordinal cycles 1 → 2 → 3.
let result = performScopedFind(surface, 'a', { forward: true, findNext: true })
expect(result.activeOrdinal).toBe(2)
result = performScopedFind(surface, 'a', { forward: true, findNext: true })
expect(result.activeOrdinal).toBe(3)
// Step backward once → 2.
result = performScopedFind(surface, 'a', { forward: false, findNext: true })
expect(result.activeOrdinal).toBe(2)
// Wrap: from the last match forward wraps to 1.
performScopedFind(surface, 'a', { forward: true, findNext: true })
performScopedFind(surface, 'a', { forward: true, findNext: true }) // → 4
result = performScopedFind(surface, 'a', { forward: true, findNext: true }) // → wraps to 1
expect(result.activeOrdinal).toBe(1)
// Backward wrap: from 1 → 4.
result = performScopedFind(surface, 'a', { forward: false, findNext: true })
expect(result.activeOrdinal).toBe(4)
})
it('lands on the LAST match when entering find mode backwards', () => {
const surface = plantSurface('surface', '<p>a a a</p>')
const result = performScopedFind(surface, 'a', { forward: false, findNext: false })
expect(result.activeOrdinal).toBe(3)
})
it('does not re-wrap when stepping with the same query (findNext fast path)', () => {
const surface = plantSurface('surface', '<p>a a a a</p>')
performScopedFind(surface, 'a', { forward: true, findNext: false })
const before = [...surface.querySelectorAll('mark.find-hit')]
const result = performScopedFind(surface, 'a', { forward: true, findNext: true })
// Same elements — the walker advanced the active marker rather than
// tearing the DOM down and rebuilding it.
const after = [...surface.querySelectorAll('mark.find-hit')]
expect(after.length).toBe(before.length)
expect(after.every(el => before.includes(el))).toBe(true)
expect(result.count).toBe(4)
expect(result.activeOrdinal).toBe(2)
})
it('does not re-wrap when the query differs only in case (fast path, triage #81778)', () => {
// The marks store the ORIGINAL-CASE source slice, so a byte-equality
// fast-path check fails on the first differently-cased match and every
// Enter re-wraps, losing `data-find-active` and pinning the ordinal to
// 1 forever. Case-insensitive comparison keeps stepping.
const surface = plantSurface('surface', '<p>Hermes Hermes</p>')
performScopedFind(surface, 'hermes', { forward: true, findNext: false })
const before = [...surface.querySelectorAll('mark.find-hit')]
const result = performScopedFind(surface, 'hermes', { forward: true, findNext: true })
const after = [...surface.querySelectorAll('mark.find-hit')]
expect(after.length).toBe(before.length)
expect(after.every(el => before.includes(el))).toBe(true)
expect(result.count).toBe(2)
expect(result.activeOrdinal).toBe(2)
})
it('re-wraps when a stale mark from an earlier query survives (fast path, #81778 review)', () => {
// Regression: a mark left over from an earlier query can match the
// current query case-insensitively even though the mark set no longer
// covers every occurrence — the genuine mark was destroyed by a
// re-render, the stale one survived, and the current query's live
// matches arrived unwrapped. The fast path must not step on the stale
// set — it must drop it and re-wrap, or the live match stays dark.
const surface = plantSurface('surface', '<p>hermes</p>')
performScopedFind(surface, 'hermes', { forward: true, findNext: false })
// Re-render replaces the paragraph (destroying the genuine mark)…
surface.querySelector('p')!.innerHTML = 'beta'
// …but a stale mark survives the edit (external write / raced cleanup),
// and the live content that matches the current query arrives unwrapped.
surface.insertAdjacentHTML('beforeend', '<mark class="find-hit">Hermes</mark>')
surface.insertAdjacentHTML('beforeend', '<p>hermes</p>')
const result = performScopedFind(surface, 'hermes', { forward: true, findNext: true })
// Re-wrapped: the stale mark's text is re-wrapped as a live occurrence
// and the previously unwrapped match is wrapped too.
expect(result.count).toBe(2)
const marks = [...surface.querySelectorAll('mark.find-hit')]
expect(marks.length).toBe(2)
expect(marks.every(mark => mark.textContent.toLowerCase() === 'hermes')).toBe(true)
})
it('re-wraps when new unmarked content contains the query (fast path, #81778 review)', () => {
// Regression: content appended after the last wrap (streamed tokens,
// external writes) carries no mark, so the existing marks no longer
// cover every occurrence. Stepping must re-wrap instead of walking the
// stale set, or the new match would never be highlighted.
const surface = plantSurface('surface', '<p>needle</p>')
performScopedFind(surface, 'needle', { forward: true, findNext: false })
surface.insertAdjacentHTML('beforeend', '<p>needle</p>')
const result = performScopedFind(surface, 'needle', { forward: true, findNext: true })
expect(result.count).toBe(2)
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
})
it('re-wraps when the query changes (marks are not reused across queries)', () => {
const surface = plantSurface('surface', '<p>alpha beta alpha</p>')
performScopedFind(surface, 'alpha', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
performScopedFind(surface, 'beta', { forward: true, findNext: false })
// The alpha marks are gone; the beta mark is wrapped.
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
expect(surface.querySelector('mark.find-hit')?.textContent).toBe('beta')
expect(surface.querySelector('p')?.textContent).toBe('alpha beta alpha')
})
it('ignores text inside the FindBar search input itself (no self-match)', () => {
// The walker skips any subtree that carries role="search" so a user
// typing "needle" never matches the placeholder text in the input.
const surface = plantSurface('surface', '')
surface.innerHTML = `
<div role="search"><input placeholder="needle placeholder" /></div>
<p>needle haystack</p>
`
const result = performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(1)
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
// The mark lives inside the <p>, not inside the search overlay.
expect(surface.querySelector('mark.find-hit')?.closest('p')).toBeTruthy()
})
it('does not match inside <script> / <style> nodes', () => {
const surface = plantSurface('surface', '')
surface.innerHTML = `
<script>const needle = 'literal'</script>
<style>.needle { color: red }</style>
<p>needle visible</p>
`
const result = performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(1)
expect(surface.querySelector('mark.find-hit')?.textContent).toBe('needle')
})
it('does not double-match when re-running the SAME query in findNext mode', () => {
const surface = plantSurface('surface', '<p>needle needle</p>')
performScopedFind(surface, 'needle', { forward: true, findNext: false })
// The walker must recognize that the existing marks already represent
// this query and advance the active marker without rebuilding.
const result = performScopedFind(surface, 'needle', { forward: true, findNext: true })
expect(result.count).toBe(2)
expect(result.activeOrdinal).toBe(2)
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
})
it('keeps searching sibling subtrees after a fully-consumed text node', () => {
// Regression (#81778 review): a text node that was entirely consumed by
// a match used to null the walker's `current`, terminating the sibling
// traversal — `<div>needle<span>needle</span></div>` only matched the
// first occurrence. The sibling subtree must still be searched.
const surface = plantSurface('surface', '<div>needle<span>needle</span></div><p>needle</p>')
const result = performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(3)
expect(surface.querySelectorAll('mark.find-hit').length).toBe(3)
})
})
describe('scope lifecycle', () => {
it('captureFindScope marks the foreground surface for the walker', () => {
const surface = plantSurface('surface', 'needle')
captureFindScope()
expect(currentFindScope()).toBe(surface)
expect(surface.hasAttribute('data-find-root')).toBe(true)
})
it('releaseFindScope unwraps every highlight and clears the scope marker', () => {
const surface = plantSurface('surface', '<p>needle in a haystack</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
releaseFindScope()
expect(surface.querySelectorAll('mark.find-hit').length).toBe(0)
expect(surface.querySelector('[data-find-root]')).toBeNull()
expect(currentFindScope()).toBeNull()
// Original text restored.
expect(surface.querySelector('p')?.textContent).toBe('needle in a haystack')
})
it('keeps the scope targeting the FOREGROUND surface even when a hidden one matches the selector first', () => {
// Regression (#81726): the visible chat is the SECOND surface in
// document order, but it must still win the scope resolution.
plantSurface('background', '<p>background needle</p>', true)
const foreground = plantSurface('foreground', '<p>foreground needle</p>')
captureFindScope()
expect(currentFindScope()).toBe(foreground)
// The walker only wraps the foreground's match.
const result = performScopedFind(foreground, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(1)
})
it('re-targets the scope to the foreground surface after a keep-alive flip hides the captured one', () => {
// Regression (#81726): the bar stays open across a tab flip (the route
// doesn't change, so the FindBar's pathname cleanup never runs). The
// captured surface's pane layer goes `data-pane-hidden` and the flipped-
// to surface is left unmarked — currentFindScope must re-resolve to the
// new foreground surface instead of going dead (0/0 forever).
const a = plantSurface('a', '<p>needle in A</p>')
const b = plantSurface('b', '<p>needle in B</p>')
captureFindScope()
expect(currentFindScope()).toBe(a)
expect(a.hasAttribute('data-find-root')).toBe(true)
// Keep-alive tab flip: A's pane layer hides, B becomes the foreground.
a.setAttribute('data-pane-hidden', '')
const scope = currentFindScope()
// The scope re-targeted to B and the marker moved with it.
expect(scope).toBe(b)
expect(b.hasAttribute('data-find-root')).toBe(true)
expect(a.hasAttribute('data-find-root')).toBe(false)
// The walker searches the new surface — and the old one is left clean.
const result = performScopedFind(scope!, 'needle', { forward: true, findNext: false })
expect(result.count).toBe(1)
expect(b.querySelectorAll('mark.find-hit').length).toBe(1)
expect(a.querySelectorAll('mark.find-hit').length).toBe(0)
})
it('re-targeting to a non-chat pane resolves to no scope (nothing on screen is a view)', () => {
// The user flipped to a pane that isn't a chat surface — nothing to
// search, matching the existing "no chat surface" contract. A later flip
// back to a visible surface re-targets again.
const a = plantSurface('a', 'needle')
captureFindScope()
a.setAttribute('data-pane-hidden', '')
expect(currentFindScope()).toBeNull()
})
})
describe('scoped find survives React re-render', () => {
// The transcript is React-owned: assistant answers stream, and a new message
// is appended whenever the assistant replies. React doesn't know about the
// `<mark>`s we insert, so a render that re-allocates a region detaches its
// marks. The scope watcher (MutationObserver) must re-wrap after the render
// commits. jsdom delivers observer callbacks + our queueMicrotask re-apply
// as microtasks, so awaiting a macrotask drains them all.
async function flushAll(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
it('re-wraps content React re-allocated, keeping the active position', async () => {
const surface = plantSurface('surface', '<p>needle hay needle</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
// Step onto match #2, then mock a React re-render that rebuilds the
// paragraph (destroying our marks) while the matching text remains.
performScopedFind(surface, 'needle', { forward: true, findNext: true })
expect(surface.querySelectorAll('mark.find-hit[data-find-active]')[0]).toBe(
surface.querySelectorAll('mark.find-hit')[1]
)
surface.querySelector('p')!.innerHTML = 'needle hay needle'
await flushAll()
// Marks restored, and the active mark is still the second match.
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
expect(surface.querySelectorAll('mark.find-hit[data-find-active]').length).toBe(1)
expect(surface.querySelectorAll('mark.find-hit[data-find-active]')[0]).toBe(
surface.querySelectorAll('mark.find-hit')[1]
)
expect(surface.querySelector('p')!.textContent).toBe('needle hay needle')
})
it('keeps existing highlights and wraps a newly appended message', async () => {
// The task's target scenario: the bar is open with highlights, then the
// assistant appends a NEW message while the list stays mounted. Existing
// highlights must persist and the appended content must not be broken.
const surface = plantSurface('surface', '<p>needle in the first message</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
// React appends a new message block.
const next = document.createElement('div')
next.innerHTML = 'second message needle'
surface.appendChild(next)
await flushAll()
// The first highlight survived the append AND the new match was wrapped
// too — nothing clobbered, nothing lost.
const marks = surface.querySelectorAll('mark.find-hit')
expect(marks.length).toBe(2)
expect(surface.querySelector('p')!.textContent).toBe('needle in the first message')
expect(next.textContent).toBe('second message needle')
})
it('does NOT re-wrap when React touched a region with no unmarked match', async () => {
const surface = plantSurface('surface', '<p>needle needle</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(2)
const before = [...surface.querySelectorAll('mark.find-hit')]
// React re-renders a paragraph that doesn't match the query — the marks
// are untouched and must not be rebuilt.
surface.insertAdjacentHTML('beforeend', '<p>irrelevant</p>')
await flushAll()
const after = [...surface.querySelectorAll('mark.find-hit')]
expect(after.length).toBe(before.length)
expect(after.every(mark => before.includes(mark))).toBe(true)
})
it('searches a typeahead that clears and re-runs still tears the watcher down', async () => {
const surface = plantSurface('surface', '<p>needle</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
// Clearing the query tears down the watcher; a later React write that
// would normally re-wrap must NOT resurrect highlights (the bar closed).
performScopedFind(surface, '', { forward: true, findNext: false })
surface.querySelector('p')!.innerHTML = 'needle again'
await flushAll()
expect(surface.querySelectorAll('mark.find-hit').length).toBe(0)
expect(surface.querySelector('p')!.textContent).toBe('needle again')
})
it('releaseFindScope detaches the watcher so no write can re-highlight', async () => {
const surface = plantSurface('surface', '<p>needle</p>')
captureFindScope()
performScopedFind(surface, 'needle', { forward: true, findNext: false })
expect(surface.querySelectorAll('mark.find-hit').length).toBe(1)
releaseFindScope()
surface.querySelector('p')!.innerHTML = 'needle needle'
await flushAll()
expect(surface.querySelectorAll('mark.find-hit').length).toBe(0)
})
})
+639
View File
@@ -0,0 +1,639 @@
/**
* Renderer-side find-in-page that scopes matches to the CURRENT VIEW — the
* active chat surface — instead of the whole document.
*
* Background. Electron's `webContents.findInPage` searches the renderer's
* entire DOM. The desktop chat shell mounts every ever-active chat surface
* simultaneously (see apps/desktop/AGENTS.md and the keep-alive comment in
* apps/desktop/src/app/chat/index.tsx), so a global search matches across
* every conversation, every background tile, and every other pane that
* happens to render a transcript. That is not what the user expects when
* they press ⌘F in a chat — they expect to search the chat they are reading
* (#81726).
*
* Strategy. At bar-open time, capture the active chat surface element and
* remember it for the lifetime of the find bar. Subsequent queries highlight
* only text nodes inside that subtree. ⌘G / ⌘⇧G step between highlights
* inside the same subtree. Closing the bar unwraps the highlights. If a
* keep-alive tab flip hides the captured surface while the bar stays open
* (the route didn't change, so the FindBar's pathname cleanup never ran),
* the scope re-resolves to the new foreground surface — "find wherever the
* user is reading" holds across a flip, not just at open time (#81726).
*
* "Current view" is the foreground `[data-chat-surface]` element after
* filtering out inactive keep-alive tabs. That is the same policy every
* other document-wide lookup obeys (see pane-visibility.ts), so this can be
* reasoned about as "find wherever the user is reading".
*
* Implementation notes:
* - We wrap each match in a `<mark class="find-hit">` so the active match is
* a single class toggle. Walking raw text nodes lets us split a node that
* spans a match boundary without disturbing the surrounding React tree
* (text nodes are inert for React reconciliation until the parent changes).
* - The active ordinal is the 1-indexed position of the match whose
* `<mark>` carries `data-active`. We re-derive it on every step instead of
* keeping a counter, so a stale count after a DOM mutation self-heals on
* the next search.
* - The transcript is React-owned and re-renders while the bar is open —
* assistant responses stream through `markdown-text.tsx`, which rebuilds
* the markdown DOM on every delta, and a new message is appended whenever
* the assistant answers. React does not know about the `<mark>`s we insert,
* so a re-render of a region whose text or structure changed detaches our
* marks (React re-allocates those nodes) while leaving marks in untouched
* regions intact. To keep highlights consistent across those renders we
* watch the captured scope with a MutationObserver and re-apply the wrap
* whenever an unmarked occurrence of the query reappears — but ONLY then,
* so a responsive render that left every mark in place costs nothing and an
* append that adds no matching text is a no-op. The observer is gated off
* while WE are mutating (a re-entrancy flag), coalesced to one re-apply per
* microtask, and torn down when the bar closes (#81726).
* - We do NOT call `webContents.findInPage` from here. That bridge still
* exists for multi-window secondary sessions (each window searches its
* own webContents), but for the primary window it cannot be scoped, so
* the renderer-side walker is the only path that satisfies the issue.
*/
import { queryVisible } from '@/components/pane-shell/pane-visibility'
const SCOPE_SELECTOR = '[data-chat-surface]'
const HIGHLIGHT_CLASS = 'find-hit'
const ACTIVE_ATTR = 'data-find-active'
const ROOT_ATTR = 'data-find-root'
// ── Re-render watch ─────────────────────────────────────────────────────────
// The transcript is React-owned and re-renders under us (streaming markdown,
// message append). React doesn't know about our `<mark>`s, so a render that
// re-allocates a region detaches the marks in that region. We watch the scope
// and re-wrap when an unmarked occurrence of the active query appears.
//
// `applying` is the re-entrancy guard: it's set for the whole synchronous
// re-apply, and any observer callback that fires during it flips `pending`
// so we take one more look afterwards instead of recursing infinitely. All
// observer work is coalesced to a single microtask so a burst of mutations
// (one streaming delta) lands as one re-apply.
let observer: MutationObserver | null = null
let scopeRoot: HTMLElement | null = null
let activeQuery = ''
let lastActiveOrdinal = 0
let applying = false
let pending = false
let scheduled = false
/**
* The element the open FindBar should search. Captured at bar-open time and
* reused for every query / step until the bar closes. Reading the visible
* chat surface here means the scope follows the user's current focus —
* pressing ⌘F in chat A searches chat A even if the route later flips to
* chat B before the user finishes typing (a typed query still targets A;
* route-change cleanup in FindBar closes the bar first).
*/
export function resolveCurrentFindScope(): HTMLElement | null {
return queryVisible<HTMLElement>(SCOPE_SELECTOR)
}
/** Capture the current view as the find scope. Called when the bar opens. */
export function captureFindScope(): HTMLElement | null {
const root = resolveCurrentFindScope()
if (root) {
root.setAttribute(ROOT_ATTR, '')
}
resetScopeState()
scopeRoot = root
return root
}
/** Forget a past scope: stop watching it and drop any queued re-apply. Called
* whenever the bar re-opens or closes so state never leaks across searches. */
function resetScopeState(): void {
observer?.disconnect()
observer = null
scopeRoot = null
activeQuery = ''
lastActiveOrdinal = 0
applying = false
pending = false
scheduled = false
}
/**
* The scope captured by the open bar, or null if none is active.
*
* When the captured surface was hidden by a keep-alive tab flip (the bar only
* closes on a ROUTE change, so a flip keeps it open over a now-inactive pane),
* re-resolves to the foreground surface instead of going dead — otherwise
* every query reports 0/0 and ⌘G becomes a no-op on the surface the user is
* actually reading (#81726).
*/
export function currentFindScope(): HTMLElement | null {
const roots = document.querySelectorAll<HTMLElement>(`[${ROOT_ATTR}]`)
for (const root of roots) {
if (!isElementInHiddenPane(root)) {
return root
}
}
if (roots.length === 0) {
// No captured scope — the bar was never opened, or releaseFindScope
// already tore it down. Nothing to re-target to.
return null
}
// Every marked root is hidden — the captured surface lost a tab flip while
// the bar stayed open. Move the scope to the now-foreground surface.
return retargetFindScope(roots)
}
/**
* Move the find scope to the foreground chat surface after a keep-alive tab
* flip hid the captured one. Clears the old surface's highlights and marker
* (they'd otherwise resurface as stray marks when its tab is revisited), then
* stamps `data-find-root` on the new surface so the walker keeps searching
* where the user is now reading. Returns the new scope, or null when no chat
* surface is visible (the user flipped to a non-chat pane — nothing on screen
* qualifies as a view).
*/
function retargetFindScope(roots: NodeListOf<HTMLElement>): HTMLElement | null {
const foreground = resolveCurrentFindScope()
if (!foreground) {
return null
}
for (const root of roots) {
if (root === foreground) {
continue
}
clearHighlights(root)
root.removeAttribute(ROOT_ATTR)
}
// The re-render watcher is still observing the OLD root; detach it so the
// next performScopedFind re-attaches to the new scope (ensureObserver only
// trusts an observer whose root matches scopeRoot).
stopObserver()
scopeRoot = foreground
foreground.setAttribute(ROOT_ATTR, '')
return foreground
}
/** Same predicate pane-visibility exposes, kept local so this module is
* independently testable without an import cycle in the renderer. */
function isElementInHiddenPane(element: Element): boolean {
return Boolean(element.closest('[data-pane-hidden]'))
}
/**
* Walk `root`'s text nodes in document order, wrapping every (case-
* insensitive) occurrence of `query` in a `<mark class="find-hit">`.
*
* Splits text nodes that span a match boundary so React (or any other
* framework holding the parent) sees the text-node change as a normal DOM
* mutation. Returns the marks in document order — the same order the step
* functions use to walk forward / backward.
*
* Implementation: walk forward, accumulating the global text offset and the
* active text node as we go. When we hit a match, split the text node,
* wrap the matched slice, and JUMP the offset past the wrapped region so we
* don't re-match inside our own `<mark>`. Crucially, we update the text-
* node pointer AFTER the wrap, so the next search step sees the new node
* layout rather than the (now detached) original.
*/
function highlightMatches(root: Element, query: string): HTMLElement[] {
const marks: HTMLElement[] = []
const lowerQuery = query.toLowerCase()
if (!lowerQuery) {
return marks
}
let current: Node | null = root.firstChild
let textNode: Text | null = null
// Outer loop: walk element children until we have a text node to search.
while (current) {
if (current.nodeType === Node.TEXT_NODE) {
textNode = current as Text
} else if (current.nodeType === Node.ELEMENT_NODE) {
const el = current as Element
// Skip elements we never want to search into. The walker is
// deliberately flat (no recursion); a nested <p> in a <section>
// becomes a fresh descendant scan via the recursive call below.
if (shouldSkipElement(el)) {
current = el.nextSibling
continue
}
// Recurse into the element's descendants.
const inner = highlightMatches(el, query)
marks.push(...inner)
current = el.nextSibling
continue
} else {
current = current.nextSibling
continue
}
if (!textNode || !textNode.parentNode) {
current = textNode?.nextSibling ?? null
continue
}
// Search within this text node. Splits may invalidate `textNode`'s
// identity, so we re-read it from the parent each iteration.
let nodeValue = textNode.nodeValue ?? ''
// Capture the text node's own next sibling BEFORE any replaceChild:
// once the original node is detached, `.nextSibling` reads null and
// the outer walker would stop, skipping every following sibling
// subtree (`<div>needle<span>needle</span></div>` searching "needle"
// matched only the first). `continueFrom` is the sibling AFTER this
// text node — for a fully-consumed match, the walker resumes there.
const continueFrom = textNode.nextSibling
while (true) {
const lower = nodeValue.toLowerCase()
const idx = lower.indexOf(lowerQuery)
if (idx === -1) {
break
}
const before = nodeValue.slice(0, idx)
const matchText = nodeValue.slice(idx, idx + lowerQuery.length)
const after = nodeValue.slice(idx + lowerQuery.length)
const parent = textNode.parentNode
if (!parent) {
break
}
const fragment = document.createDocumentFragment()
if (before) {
fragment.appendChild(document.createTextNode(before))
}
const mark = document.createElement('mark')
mark.className = HIGHLIGHT_CLASS
mark.textContent = matchText
fragment.appendChild(mark)
// Capture the after-sibling BEFORE replaceChild — replaceChild moves
// the fragment's children into the parent and empties the fragment,
// so looking at `fragment.lastChild` afterwards would point at a
// detached `<mark>` (or null if `before` was empty).
const afterNode = after ? document.createTextNode(after) : null
if (afterNode) {
fragment.appendChild(afterNode)
}
parent.replaceChild(fragment, textNode)
marks.push(mark)
if (!afterNode) {
// Whole text node consumed — nothing left to scan in this region.
textNode = null
break
}
textNode = afterNode
nodeValue = afterNode.nodeValue ?? ''
}
if (textNode) {
current = textNode.nextSibling
} else {
// The whole text node was consumed by matches — `textNode` was
// detached by replaceChild so its own `.nextSibling` is null. Resume
// the outer walker from the parent's next sibling (captured before
// the first replaceChild), so the sibling subtree is still searched.
current = continueFrom
}
}
return marks
}
/** Elements we never want to descend into during a search. Mirrors the
* filter the TreeWalker applied in the original design. */
function shouldSkipElement(el: Element): boolean {
const tag = el.tagName
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') {
return true
}
if (el.closest(`mark.${HIGHLIGHT_CLASS}`)) {
return true
}
if (el.closest('[role="search"]')) {
return true
}
return false
}
/**
* True when `root` contains an occurrence of `query` that is NOT inside one
* of our find-hit marks. Read-only counterpart of `highlightMatches` with
* the identical skip rules, used by the findNext fast path to verify that
* the existing marks still cover every match — a stale mark from an earlier
* query can satisfy the per-mark text comparison yet leave live occurrences
* unwrapped, and stepping on that set would never highlight them.
*/
function hasUnmarkedMatch(root: Element, query: string): boolean {
const lowerQuery = query.toLowerCase()
if (!lowerQuery) {
return false
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
const node = walker.currentNode as Text
const parent = node.parentElement
if (parent && shouldSkipElement(parent)) {
continue
}
if (node.nodeValue?.toLowerCase().includes(lowerQuery)) {
return true
}
}
return false
}
/** Remove every find-hit mark we previously added, restoring original text. */
function clearHighlights(root: Element): void {
const marks = root.querySelectorAll<HTMLElement>(`mark.${HIGHLIGHT_CLASS}`)
for (const mark of marks) {
const parent = mark.parentNode
if (!parent) {
continue
}
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark)
}
parent.removeChild(mark)
parent.normalize()
}
}
/** Mark one element as the active match and scroll it into view. */
function setActiveMark(mark: HTMLElement | null): void {
document.querySelectorAll(`mark[${ACTIVE_ATTR}]`).forEach(el => el.removeAttribute(ACTIVE_ATTR))
if (!mark) {
return
}
mark.setAttribute(ACTIVE_ATTR, '')
// Block: 'nearest' so a match already on screen doesn't twitch, but a
// match below the fold scrolls into view instead of silently landing off-
// screen. Inline: 'nearest' for the same reason. Guarded: jsdom does not
// implement scrollIntoView, and the bar still needs to highlight even
// when the renderer side has no layout (tests, headless boot, …).
if (typeof mark.scrollIntoView === 'function') {
mark.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
/** Result of a find / step — the same shape the bar already shows. */
export interface ScopedFindResult {
count: number
activeOrdinal: number
}
export interface ScopedFindOptions {
forward: boolean
findNext: boolean
}
const DEFAULT_RESULT: ScopedFindResult = { count: 0, activeOrdinal: 0 }
/**
* Run a scoped find against `root`. When `findNext` is true, advance / step
* the active mark without re-highlighting (the query has not changed). When
* false, drop prior highlights and re-wrap matches for `query`.
*
* Returns the (count, activeOrdinal) the bar should display. Returning a
* plain object instead of pushing into the store keeps this helper testable
* without a nanostores harness — the store wires it up.
*/
export function performScopedFind(root: Element, query: string, options: ScopedFindOptions): ScopedFindResult {
if (!query) {
clearHighlights(root)
setActiveMark(null)
// No query, no marks to maintain — stop watching and forget the query.
activeQuery = ''
lastActiveOrdinal = 0
stopObserver()
return DEFAULT_RESULT
}
const existingMarks = [...root.querySelectorAll<HTMLElement>(`mark.${HIGHLIGHT_CLASS}`)]
// The marks store the ORIGINAL-CASE source slice (`highlightMatches`
// writes `mark.textContent = matchText`), so byte-equality against the
// typed query fails on the first match whose casing differs — 'Hermes'
// for 'hermes', sentence-initial capitals, ALL-CAPS. Without the
// case-insensitive comparison, every Enter/⌘G step re-wraps all
// highlights, `data-find-active` is lost on the fresh DOM, and the
// active ordinal resets to 1 forever (triage finding on #81778).
// The per-mark comparison alone is not enough: a stale mark from an
// earlier query that survived a raced re-wrap (or external DOM writes)
// can match the current query case-insensitively while the mark set no
// longer covers every match — stepping would walk old highlights and
// never wrap the live ones. Only step when the marks match AND cover
// every occurrence (review finding on #81778).
const sameQuery =
options.findNext &&
existingMarks.length > 0 &&
existingMarks.every(mark => mark.textContent.toLowerCase() === query.toLowerCase()) &&
!hasUnmarkedMatch(root, query)
let marks = existingMarks
if (!sameQuery) {
// Mutate under the re-entrancy guard so the re-render watcher doesn't
// fire on the marks WE are replacing (it has nothing to repair yet).
applying = true
try {
clearHighlights(root)
marks = highlightMatches(root, query)
} finally {
applying = false
}
}
if (marks.length === 0) {
setActiveMark(null)
// A query that matches nothing has nothing to maintain.
activeQuery = ''
lastActiveOrdinal = 0
stopObserver()
return DEFAULT_RESULT
}
const previousActive = root.querySelector<HTMLElement>(`mark[${ACTIVE_ATTR}]`)
let nextIndex = 0
if (previousActive) {
const previousIndex = marks.indexOf(previousActive)
if (previousIndex !== -1) {
nextIndex = options.forward
? (previousIndex + 1) % marks.length
: (previousIndex - 1 + marks.length) % marks.length
}
} else if (!options.forward) {
// Entering find mode backwards (Shift+Enter on first press): land on the
// last match, matching browser convention.
nextIndex = marks.length - 1
}
setActiveMark(marks[nextIndex] ?? null)
// A live search must survive React re-rendering the transcript under us.
// Remember the query + active position and start watching the scope; the
// observer re-wraps when a render detaches our marks and re-activates the
// same ordinal so the user's place doesn't reset to match #1 on the next
// streamed token.
activeQuery = query
lastActiveOrdinal = nextIndex + 1
ensureObserver(root)
return { count: marks.length, activeOrdinal: nextIndex + 1 }
}
// ── Re-apply on React re-render ─────────────────────────────────────────────
/**
* Attach the scope watcher, if it isn't already attached. Only observes the
* current scope; a fresh `captureFindScope` (or close) detaches it.
*/
function ensureObserver(root: Element): void {
if (observer && scopeRoot === root) {
return
}
stopObserver()
observer = new MutationObserver(() => scheduleReapply())
observer.observe(root, { childList: true, subtree: true })
}
function stopObserver(): void {
observer?.disconnect()
observer = null
}
/**
* Coalesce a mutation burst (one streaming delta) into a single re-apply that
* runs on the next microtask. A no-op when the marks still cover the query —
* React reusing an untouched region doesn't need any work.
*/
function scheduleReapply(): void {
if (applying) {
// A mutation landed while we were mutating the tree. We'll look again
// once this apply finishes rather than recursing now.
pending = true
return
}
if (scheduled || !scopeRoot || !activeQuery) {
return
}
scheduled = true
queueMicrotask(() => {
scheduled = false
if (applying || !scopeRoot || !activeQuery) {
return
}
// Nothing to repair: every occurrence is still wrapped (React only
// touched a region that doesn't match the query).
if (!hasUnmarkedMatch(scopeRoot, activeQuery)) {
return
}
reapplying(scopeRoot)
})
}
/**
* Re-wrap every occurrence in the scope after React detached our previous
* marks, re-activating the position the user was on. Runs with `applying` set
* so the observer ignores the mutations WE make, then drains any `pending`
* mutation that arrived during the apply.
*/
function reapplying(root: HTMLElement): void {
const restoreOrdinal = lastActiveOrdinal
applying = true
try {
clearHighlights(root)
const marks = highlightMatches(root, activeQuery)
if (marks.length === 0) {
return
}
// Re-activate the same ordinal the user last stood on, clamped to the
// re-wrapped set, so a mid-stream re-render doesn't bounce their place
// back to match #1.
const nextIndex = Math.min(restoreOrdinal - 1, marks.length - 1)
setActiveMark(marks[nextIndex] ?? null)
lastActiveOrdinal = nextIndex + 1
} finally {
applying = false
}
if (pending) {
pending = false
scheduleReapply()
}
}
/** Tear down highlights and the scope marker — called when the bar closes. */
export function releaseFindScope(): void {
const roots = document.querySelectorAll<HTMLElement>(`[${ROOT_ATTR}]`)
for (const root of roots) {
clearHighlights(root)
root.removeAttribute(ROOT_ATTR)
}
setActiveMark(null)
resetScopeState()
}
+109
View File
@@ -0,0 +1,109 @@
// Pure logic for the find-in-page bar (⌘F). Kept out of the component so the
// match-counter projection and the in-bar key routing can be unit-tested
// without jsdom, a BrowserWindow, or the preload bridge.
//
// The Electron side of the feature lives in electron/find-in-page.ts; this is
// strictly renderer presentation logic.
/**
* Counter shown next to the input, e.g. `"3/12"`.
*
* Three distinct states, and they are not the same thing:
* - No query → `''`. The counter hides entirely rather than claiming "0/0"
* before the user has asked anything.
* - A query with no matches → `'0/0'`. An explicit, honest zero.
* - A query with matches → `'<ordinal>/<count>'`.
*
* `activeMatchOrdinal` is 1-indexed and can legitimately arrive as 0 from
* Electron for the frame between issuing a search and the first match being
* selected, so the ordinal is clamped into `[0, count]` rather than trusted.
*/
export function formatMatchLabel(query: string, activeMatchOrdinal: number, matchCount: number): string {
if (!query) {
return ''
}
const count = Number.isFinite(matchCount) && matchCount > 0 ? Math.floor(matchCount) : 0
if (count === 0) {
return '0/0'
}
const raw = Number.isFinite(activeMatchOrdinal) ? Math.floor(activeMatchOrdinal) : 0
const ordinal = Math.min(Math.max(raw, 0), count)
return `${ordinal}/${count}`
}
/** What a keypress means to an open find bar. `null` = not ours, let it through. */
export type FindBarKeyAction = 'close' | 'next' | 'previous' | null
/** The subset of a keyboard event the matcher needs — works for DOM and React events. */
export interface FindBarKeyEvent {
key: string
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
}
/**
* Map a keypress to a find-bar action while the bar is open.
*
* Two families, matching the platform convention Chrome/Safari/VS Code (and
* Claude Desktop's `findInPage` accelerators) all share:
* - Bare `Enter` / `Shift+Enter` step forward / backward. Only valid while
* focus is in the find input, so callers pass `inInput: true` there.
* - `⌘G` / `⌘⇧G` (Ctrl+G / Ctrl+Shift+G off macOS) step forward / backward
* from anywhere while the bar is open — that is the accelerator pair, and it
* must not require the input to hold focus.
* - `Escape` closes from anywhere.
*
* `Alt` is treated as disqualifying so ⌥⌘G and friends fall through to
* whatever else may want them instead of being silently swallowed.
*/
export function findBarKeyAction(event: FindBarKeyEvent, options: { inInput?: boolean } = {}): FindBarKeyAction {
if (event.altKey) {
return null
}
const mod = Boolean(event.metaKey || event.ctrlKey)
if (event.key === 'Escape') {
return mod ? null : 'close'
}
// `event.key` for the G key is 'g' unshifted and 'G' with Shift held, so
// compare case-insensitively and read direction from `shiftKey` alone.
if (mod && event.key.toLowerCase() === 'g') {
return event.shiftKey ? 'previous' : 'next'
}
if (event.key === 'Enter' && !mod && options.inInput) {
return event.shiftKey ? 'previous' : 'next'
}
return null
}
/**
* Combos the open find bar owns, in canonical `comboFromEvent` form.
*
* The global keybind dispatcher (app/hooks/use-keybinds.ts) consults this
* before routing a combo to the registry. Without it, three real collisions
* fire alongside the find bar:
* - `mod+g` → `view.toggleReview` (⌘G is the review pane's default).
* - `mod+shift+g` → whatever a user has bound there.
* - `escape` → `composer.cancel`, which would abort a running turn while the
* user only meant to dismiss the find bar.
*
* `stopPropagation` cannot solve this: both listeners sit on `window` in the
* capture phase, and propagation control does not suppress sibling listeners
* on the same target. Ownership has to be decided by the dispatcher, which is
* the documented single owner of combo dispatch. This matches the
* "keyboard ownership follows focus / one cancel gesture does one thing"
* invariant in apps/desktop/AGENTS.md.
*/
export function findBarClaimsCombo(combo: string): boolean {
return combo === 'mod+g' || combo === 'mod+shift+g' || combo === 'escape'
}
+24
View File
@@ -0,0 +1,24 @@
// THE compact-number formatter — every user-facing count/token figure goes
// through here. 999 → "999", 1000 → "1k", 1230 → "1.2k", 10000 → "10k",
// 1_500_000 → "1.5M". Do not hand-roll `/ 1000` display math elsewhere.
export function compactNumber(value: null | number | undefined): string {
const num = Number(value ?? 0)
if (!Number.isFinite(num) || num <= 0) {
return '0'
}
const scaled = (v: number, suffix: string) => `${v.toFixed(1).replace(/\.0$/, '')}${suffix}`
// Thresholds sit just under the unit boundary so rounding can't produce
// "1000k" or "1000" — those promote to the next unit instead.
if (num >= 999_950) {
return scaled(num / 1_000_000, 'M')
}
if (num >= 999.5) {
return scaled(num / 1_000, 'k')
}
return `${Math.round(num)}`
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { approvalReplaySessionId, gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('rehydrates pending approvals on reconnect ready and resumed session info', () => {
expect(approvalReplaySessionId('gateway.ready', 'active-1', null)).toBe('active-1')
expect(approvalReplaySessionId('session.info', 'active-1', 'routed-1')).toBe('routed-1')
expect(approvalReplaySessionId('message.delta', 'active-1', 'routed-1')).toBeNull()
})
it('does not replay against an active runtime the gateway already reported gone', () => {
const isGone = (sid: string) => sid === 'dead-1'
// Unscoped fan-out attributed to a dead active session: skip.
expect(approvalReplaySessionId('session.info', 'dead-1', 'dead-1', { explicit: false, isGone })).toBeNull()
expect(approvalReplaySessionId('gateway.ready', 'dead-1', null, { explicit: false, isGone })).toBeNull()
// A live active session still replays.
expect(approvalReplaySessionId('session.info', 'live-1', 'live-1', { explicit: false, isGone })).toBe('live-1')
// An explicitly scoped frame is the runtime speaking for itself — never skipped.
expect(approvalReplaySessionId('session.info', 'dead-1', 'dead-1', { explicit: true, isGone })).toBe('dead-1')
})
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
})
it('attributes unscoped foreground turn events to the active chat', () => {
// These must NOT be dropped when unscoped — they are the focused turn's own
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
expect(gatewayEventRequiresSessionId('message.interim')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
})
it('allows global events to remain unscoped', () => {
expect(gatewayEventRequiresSessionId('gateway.ready')).toBe(false)
expect(gatewayEventRequiresSessionId('preview.restart.progress')).toBe(false)
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
})
it('keeps unscoped stream events pinned to the session that started them', () => {
const started = resolveGatewayEventSessionId({
activeSessionId: 'session-a',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: null
})
expect(started).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
pinned: false,
sessionId: 'session-a'
})
const delta = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.delta',
explicitSessionId: '',
unscopedStreamSessionId: started.nextUnscopedStreamSessionId
})
expect(delta).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
pinned: true,
sessionId: 'session-a'
})
const completed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: '',
unscopedStreamSessionId: delta.nextUnscopedStreamSessionId
})
expect(completed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
pinned: true,
sessionId: 'session-a'
})
})
it('routes a new unscoped stream start to the currently active session', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: 'session-a'
})
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-b',
pinned: false,
sessionId: 'session-b'
})
})
it('attributes an unpinned stream event to the active session without the pin flag', () => {
// A late straggler (no pin left after the previous turn completed) falls
// back to the active session. The handler drops this case when the target
// session has no live turn — the straggler belongs to a turn that already
// ended elsewhere (#43142 family).
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'thinking.delta',
explicitSessionId: '',
unscopedStreamSessionId: null
})
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
pinned: false,
sessionId: 'session-b'
})
})
it('keeps explicit events scoped and clears a matching pinned stream on completion', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: 'session-a',
unscopedStreamSessionId: 'session-a'
})
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
pinned: true,
sessionId: 'session-a'
})
})
})
+202
View File
@@ -0,0 +1,202 @@
import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls'
const LOG_TAIL = 5
interface RpcEventLike {
payload?: unknown
type?: string
}
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Unscoped stream events that must stay pinned to the session that received
* ``message.start`` after the user switches chats mid-turn (#47709 / #48281).
* Without this, ``explicitSid || activeSessionId`` reattributes live deltas to
* the newly focused chat.
*/
/** Unscoped stream events that must stay pinned to the session that received
* ``message.start`` after the user switches chats mid-turn (#47709 / #48281).
* Without this, ``explicitSid || activeSessionId`` reattributes live deltas to
* the newly focused chat. Exported so the event handler can tell which events
* are pin-eligible when deciding whether an unpinned straggler is legitimate. */
export const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'approval.request',
'browser.progress',
'clarify.request',
'error',
'mcp.setup.request',
'message.complete',
'message.delta',
'message.interim',
'message.start',
'reasoning.available',
'reasoning.delta',
'secret.request',
'status.update',
'sudo.request',
'thinking.delta',
'tool.complete',
'tool.generating',
'tool.progress',
'tool.start'
])
const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete'])
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
*
* Only `subagent.*` qualifies: it describes background/async work that must
* never attach to whichever chat happens to be focused. Every other scoped
* event — message/reasoning/thinking/tool/status/prompt — is, when unscoped,
* the active turn's own output. The gateway always stamps a *background*
* session's events with that session's id, so a missing id can only mean "the
* focused turn". #42178 dropped those too, which silently swallowed the live
* answer; it then reappeared only after a transcript refetch (manual refresh).
*/
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
return eventType?.startsWith('subagent.') ?? false
}
export interface GatewayEventSessionRouteInput {
activeSessionId: null | string
eventType: string | undefined
explicitSessionId: string
unscopedStreamSessionId: null | string
}
export interface GatewayEventSessionRoute {
drop: boolean
nextUnscopedStreamSessionId: null | string
/** True when the event was attributed via the pinned stream session rather
* than the active-session fallback. The caller uses this to drop late
* stragglers: an unpinned stream event landing on a session that has no
* live turn belongs to a turn that already ended elsewhere. */
pinned: boolean
sessionId: null | string
}
/** Which session (if any) to re-pull `approval.pending` for after `eventType`.
*
* `gateway.ready` and `session.info` are the two rehydration points. An
* UNSCOPED `session.info` (the approvals-loop / broadcast fan-out, no
* `session_id` on the frame) reaches here attributed to the active session by
* the routing fallback; when `isGone(activeSessionId)` — the gateway already
* answered 4001 for that runtime — replaying would only re-send the dead id
* on every fan-out tick (#100639), so return null. A frame that names the
* session explicitly is the runtime speaking for itself and is never gone. */
export function approvalReplaySessionId(
eventType: string | undefined,
activeSessionId: null | string,
routedSessionId: null | string,
options?: { explicit?: boolean; isGone?: (sessionId: string) => boolean }
): null | string {
let target: null | string = null
if (eventType === 'gateway.ready') {
target = activeSessionId
} else if (eventType === 'session.info') {
target = routedSessionId
}
if (target && !options?.explicit && options?.isGone?.(target)) {
return null
}
return target
}
/**
* Resolve which runtime session owns a gateway event.
*
* Explicit ``session_id`` always wins. Unscoped stream events pin to the
* session that received ``message.start`` so a mid-turn chat switch cannot
* steal live deltas / tool events onto the newly focused transcript.
*/
export function resolveGatewayEventSessionId({
activeSessionId,
eventType,
explicitSessionId,
unscopedStreamSessionId
}: GatewayEventSessionRouteInput): GatewayEventSessionRoute {
if (explicitSessionId) {
const nextUnscopedStreamSessionId =
eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId
? null
: unscopedStreamSessionId
return {
drop: false,
nextUnscopedStreamSessionId,
pinned: true,
sessionId: explicitSessionId
}
}
if (gatewayEventRequiresSessionId(eventType)) {
return {
drop: true,
nextUnscopedStreamSessionId: unscopedStreamSessionId,
pinned: false,
sessionId: null
}
}
const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false
const sessionId =
eventType === 'message.start'
? activeSessionId
: streamEvent
? unscopedStreamSessionId || activeSessionId
: activeSessionId
let nextUnscopedStreamSessionId = unscopedStreamSessionId
if (eventType === 'message.start' && activeSessionId) {
nextUnscopedStreamSessionId = activeSessionId
} else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) {
nextUnscopedStreamSessionId = null
}
return {
drop: false,
nextUnscopedStreamSessionId,
pinned: streamEvent && eventType !== 'message.start' && Boolean(unscopedStreamSessionId),
sessionId
}
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false
}
const diff = asRecord(event.payload).inline_diff
return typeof diff === 'string' && diff.trim().length > 0
}
export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
if (lines.length === 0) {
return [
{
className: 'text-muted-foreground',
disabled: true,
id: 'gateway-log-empty',
label: 'No recent gateway log lines'
}
]
}
return lines.slice(-LOG_TAIL).map((line, index) => ({
className: 'font-mono text-[0.68rem] text-muted-foreground',
disabled: true,
id: `gateway-log:${index}`,
label: line.trim().slice(0, 120) || '(blank log line)'
}))
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
decideLivenessForceClose,
LIVENESS_PROBE_FAILURE_STREAK,
LIVENESS_REPROBE_DELAY_MS
} from './gateway-liveness-policy'
describe('decideLivenessForceClose', () => {
it('keeps the socket on the first timeout while a turn is in flight (#95327)', () => {
const decision = decideLivenessForceClose({ workingSessionCount: 1, consecutiveFailures: 1 })
expect(decision.close).toBe(false)
expect(decision.reason).toBe('in-flight-work-deferred')
})
it('defers for every busy session count, not just one', () => {
for (const workingSessionCount of [1, 2, 7]) {
const decision = decideLivenessForceClose({ workingSessionCount, consecutiveFailures: 1 })
expect(decision).toEqual({ close: false, reason: 'in-flight-work-deferred' })
}
})
it('closes once the unanswered streak reaches the limit even while busy', () => {
const decision = decideLivenessForceClose({
workingSessionCount: 3,
consecutiveFailures: LIVENESS_PROBE_FAILURE_STREAK
})
expect(decision.close).toBe(true)
expect(decision.reason).toBe('failure-streak-exhausted')
})
it('closes immediately with no work in flight (legacy shape unchanged)', () => {
const decision = decideLivenessForceClose({ workingSessionCount: 0, consecutiveFailures: 1 })
expect(decision.close).toBe(true)
expect(decision.reason).toBe('no-in-flight-work')
})
it('never lets a deferred defer outlive the streak boundary', () => {
// Walk the whole streak: every failure below the limit defers; the limit
// itself and anything beyond close.
for (let failures = 1; failures <= LIVENESS_PROBE_FAILURE_STREAK + 2; failures += 1) {
const decision = decideLivenessForceClose({ workingSessionCount: 1, consecutiveFailures: failures })
expect(decision.close).toBe(failures >= LIVENESS_PROBE_FAILURE_STREAK)
}
})
it('coerces malformed counters defensively instead of throwing', () => {
expect(decideLivenessForceClose({ workingSessionCount: Number.NaN, consecutiveFailures: 0 })).toEqual({
close: true,
reason: 'no-in-flight-work'
})
expect(decideLivenessForceClose({ workingSessionCount: -3, consecutiveFailures: -10 })).toEqual({
close: true,
reason: 'no-in-flight-work'
})
})
it('keeps the re-probe delay far below the probe budget stack so detection stays bounded', () => {
// The re-probe is a second 5s liveness ping after this delay; together
// they must stay well under the reconnect-escalation horizon (5 min).
expect(LIVENESS_REPROBE_DELAY_MS).toBeGreaterThan(0)
expect(LIVENESS_REPROBE_DELAY_MS).toBeLessThan(60_000)
})
})
@@ -0,0 +1,85 @@
/**
* Liveness-probe force-close policy for the primary gateway socket (#95327).
*
* Why this exists
* ───────────────
* Every window-lifecycle recovery signal (power resume, network online,
* focus, visibility) nudges `reconnectNow()`. When the socket still reports
* open, that path probes liveness with a short-bounded ping and FORCE-CLOSES
* the socket when the ping times out — "a half-open TCP connection must not
* swallow the user's next submit".
*
* A ping timeout proves a dead TRANSPORT, but it also fires for a live,
* BUSY backend: a long silent tool call (a quiet build, an OCR worker, a
* large download) starves the gateway's event loop past the 5s probe budget
* (#74874's GIL-stall family, amplified on Windows by AV/filter-driver
* latency). Tearing down the renderer↔backend WebSocket at that moment is a
* false kill: the gateway sees its client vanish mid-turn, the
* `ws_orphan_reap` grace expires, and the running turn is interrupted into a
* bare "Operation interrupted." placeholder — the exact #95327 report.
*
* Policy
* ──────
* While ANY session still reports working (`$workingSessionIds`), backend
* silence is EXPECTED, so a probe timeout is inconclusive rather than proof
* of death. The first such timeout is deferred: keep the socket and schedule
* one bounded re-probe instead. Only when the failure STREAK reaches
* `LIVENESS_PROBE_FAILURE_STREAK` (the re-probe also went unanswered — or no
* turn is in flight at all) do we treat the socket as genuinely dead and let
* the normal close→backoff→redial machinery take over. Worst case this adds
* one re-probe interval to genuine-dead detection; it removes the mid-turn
* teardown entirely for busy-but-healthy backends.
*
* Pure and Electron-free so the streak boundaries are assertable directly
* (mirroring gateway-liveness usage in use-gateway-boot).
*/
/** Consecutive unanswered probes tolerated while work is in flight. */
export const LIVENESS_PROBE_FAILURE_STREAK = 2
/** How long after a deferred probe we try again (bounded, coalesced). */
export const LIVENESS_REPROBE_DELAY_MS = 3_000
export interface LivenessForceCloseInput {
/**
* How many sessions currently report working (mid-turn). Zero means no
* turn is riding this socket, so silence has no innocent explanation.
*/
workingSessionCount: number
/**
* Length of the CURRENT unanswered-probe streak, INCLUDING the failure
* being decided (first failure = 1).
*/
consecutiveFailures: number
}
export type LivenessForceCloseReason = 'in-flight-work-deferred' | 'failure-streak-exhausted' | 'no-in-flight-work'
export interface LivenessForceCloseDecision {
close: boolean
reason: LivenessForceCloseReason
}
/**
* Decide whether one liveness-probe failure should force the socket down.
*
* - No work in flight → close immediately (unchanged legacy shape:
* a dead idle socket buys nothing by waiting).
* - Work in flight, streak < max → keep the socket; the caller schedules a
* bounded re-probe ('in-flight-work-deferred').
* - Work in flight, streak ≥ max → close anyway ('failure-streak-exhausted'):
* a persistently unresponsive socket must
* still be rebuilt, never trusted forever.
*/
export function decideLivenessForceClose(input: LivenessForceCloseInput): LivenessForceCloseDecision {
const workingSessionCount = Math.max(0, Math.floor(input.workingSessionCount))
const consecutiveFailures = Math.max(1, Math.floor(input.consecutiveFailures))
if (workingSessionCount > 0 && consecutiveFailures < LIVENESS_PROBE_FAILURE_STREAK) {
return { close: false, reason: 'in-flight-work-deferred' }
}
return workingSessionCount > 0
? { close: true, reason: 'failure-streak-exhausted' }
: { close: true, reason: 'no-in-flight-work' }
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc'
describe('isMissingRpcMethod', () => {
it('detects JSON-RPC method-not-found errors', () => {
expect(isMissingRpcMethod(new Error('unknown method: projects.create'))).toBe(true)
expect(isMissingRpcMethod(new Error('Method not found'))).toBe(true)
expect(isMissingRpcMethod(new Error('RPC failed: -32601'))).toBe(true)
})
it('ignores unrelated failures', () => {
expect(isMissingRpcMethod(new Error('Hermes gateway is not connected'))).toBe(false)
expect(isMissingRpcMethod(new Error('no such project'))).toBe(false)
})
})
describe('isMissingPendingPromptRequest', () => {
it('detects stale prompt response errors from the gateway', () => {
expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true)
expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true)
})
it('ignores unrelated gateway failures', () => {
expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false)
expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false)
})
})
+39
View File
@@ -0,0 +1,39 @@
/** True when a JSON-RPC call failed because the backend predates the method. */
export function isMissingRpcMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}
/** REST twin of isMissingRpcMethod: the route does not exist on this backend.
* Matches the backend catch-all ('404: {"detail":"No such API endpoint: …}'),
* FastAPI's bare 404 on headless serve — directly, or wrapped as "Error
* invoking remote method 'hermes:api': Error: 404: …" through the IPC bridge
* — and the Electron JSON-guard ("endpoint is likely missing"). Transient
* failures (timeouts, 5xx, connection refused) must NOT match: they are
* retryable, not a capability verdict. Only sound for calls where a 404 can
* mean nothing else — a route with path params can 404 on a bad id. */
export function isMissingRestEndpoint(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return (
/no such api endpoint/i.test(message) ||
/endpoint is likely missing/i.test(message) ||
/(?:^\s*|error:\s*)404\b/i.test(message)
)
}
/** True when a prompt response raced a backend-side timeout / completion. */
export function isMissingPendingPromptRequest(error: unknown, key: string): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`)
}
/** True when a pre-deferral backend refused a mid-turn model switch (4009).
* Current gateways park the pick and answer `scope: "pending"` instead. */
export function isBusySessionModelSwitch(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session busy/i.test(message) && /switching models/i.test(message)
}
+116
View File
@@ -0,0 +1,116 @@
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
import { describe, expect, it, vi } from 'vitest'
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
describe('resolveGatewayWsUrl', () => {
describe('oauth mode', () => {
it('uses the freshly minted URL', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
})
it('uses the structured URL returned across the Electron IPC boundary', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?ticket=fresh' })
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
})
it('throws a reauth error when the main process reports an auth rejection', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue({
error: '401 cookie expired',
needsOauthLogin: true,
ok: false
})
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
GatewayReauthRequiredError
)
})
it('preserves the main-process auth failure as the cause', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue({
error: '401 cookie expired',
needsOauthLogin: true,
ok: false
})
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
expect((error as GatewayReauthRequiredError).cause).toMatchObject({ message: '401 cookie expired' })
})
it('keeps a transport failure retryable instead of demanding sign-in', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue({ error: 'gateway timed out', ok: false })
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
expect(error).toMatchObject({ message: 'gateway timed out' })
expect(isGatewayReauthRequired(error)).toBe(false)
})
it('rethrows an unexpected transport rejection unchanged', async () => {
const cause = new Error('socket closed')
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause)
})
it('reports a missing preload method as an app capability error, not reauth', async () => {
const error = await resolveGatewayWsUrl({}, oauthConn).catch(e => e)
expect(error).toMatchObject({ message: expect.stringMatching(/cannot refresh OAuth WebSocket tickets/i) })
expect(isGatewayReauthRequired(error)).toBe(false)
})
it('never returns the stale cached ticket on failure', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom'))
const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw')
expect(result).toBe('threw')
expect(result).not.toBe(oauthConn.wsUrl)
})
})
describe('token / local mode', () => {
it('uses the minted URL when available', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
})
it('uses a structured refreshed token URL when available', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?token=fresh' })
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
})
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('falls back to the cached URL when the preload method is absent', async () => {
await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('treats a missing authMode as non-oauth (falls back safely)', async () => {
await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl)
})
})
})
describe('isGatewayReauthRequired', () => {
it('detects the dedicated error class', () => {
expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true)
})
it('detects plain objects tagged with needsOauthLogin (from the main process)', () => {
expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true)
})
it('rejects generic errors', () => {
expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false)
expect(isGatewayReauthRequired(null)).toBe(false)
expect(isGatewayReauthRequired('string')).toBe(false)
})
})
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
dedupeGeneratedImageEchoesInParts,
generatedImageEchoSources,
generatedImageFromResult,
stripGeneratedImageEchoes
} from './generated-images'
describe('generatedImageFromResult', () => {
it('prefers the host-visible image path', () => {
expect(
generatedImageFromResult({
agent_visible_image: '/container/cache/cat.png',
host_image: '/Users/me/.hermes/cache/images/cat.png',
image: '/Users/me/.hermes/cache/images/cat.png',
success: true
})
).toBe('/Users/me/.hermes/cache/images/cat.png')
})
it('ignores failed image generation results', () => {
expect(generatedImageFromResult({ image: 'https://cdn.example/cat.png', success: false })).toBeNull()
})
})
describe('stripGeneratedImageEchoes', () => {
it('removes repeated generated image markdown without removing prose', () => {
expect(
stripGeneratedImageEchoes('Here you go.\n\n![Generated image](https://cdn.example/cat.png)', [
'https://cdn.example/cat.png'
])
).toBe('Here you go.')
})
it('removes media links for generated local image paths', () => {
expect(stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])).toBe(
'Saved image:'
)
})
})
describe('generatedImageEchoSources', () => {
it('collects every path variant the model might restate', () => {
expect(
generatedImageEchoSources([
{
result: {
agent_visible_image: '/sandbox/cat.png',
host_image: '/host/cat.png',
image: '/host/cat.png',
success: true
},
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual(['/host/cat.png', '/sandbox/cat.png'])
})
})
describe('dedupeGeneratedImageEchoesInParts', () => {
it('keeps the agent prose while removing the duplicated image', () => {
expect(
dedupeGeneratedImageEchoesInParts([
{ text: 'Here is your peacock! ![peacock](/host/p.png) Enjoy.', type: 'text' },
{
result: { host_image: '/host/p.png', image: '/host/p.png', success: true },
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual([
{ text: 'Here is your peacock! Enjoy.', type: 'text' },
{
result: { host_image: '/host/p.png', image: '/host/p.png', success: true },
toolName: 'image_generate',
type: 'tool-call'
}
])
})
it('strips a sandbox path the model restated instead of the host path', () => {
expect(
dedupeGeneratedImageEchoesInParts([
{ text: '![cat](/sandbox/cat.png)', type: 'text' },
{
result: {
agent_visible_image: '/sandbox/cat.png',
host_image: '/host/cat.png',
image: '/host/cat.png',
success: true
},
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual([
{
result: {
agent_visible_image: '/sandbox/cat.png',
host_image: '/host/cat.png',
image: '/host/cat.png',
success: true
},
toolName: 'image_generate',
type: 'tool-call'
}
])
})
it('leaves pending generations untouched so the agent prose survives', () => {
const parts = [
{ text: 'Another peacock, coming up!', type: 'text' },
{ result: undefined, toolName: 'image_generate', type: 'tool-call' }
]
expect(dedupeGeneratedImageEchoesInParts(parts)).toEqual(parts)
})
})
+114
View File
@@ -0,0 +1,114 @@
type ToolLike = {
result?: unknown
toolName?: unknown
type?: unknown
}
type TextLike = {
text?: unknown
type?: unknown
}
// Path-ish result fields the model may echo into its prose. Display prefers the
// host path (gateway-deliverable); stripping must catch every variant so a
// sandbox path the model restated doesn't slip through as a duplicate image.
const DISPLAY_KEYS = ['host_image', 'image'] as const
const ECHO_KEYS = ['host_image', 'image', 'agent_visible_image'] as const
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string' || !value.trim()) {
return null
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
} catch {
return null
}
}
function stringFields(record: Record<string, unknown>, keys: readonly string[]): string[] {
return keys.map(key => record[key]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
}
function regexEscape(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]
}
function imageResult(part: ToolLike): Record<string, unknown> | null {
if (part.type !== 'tool-call' || part.toolName !== 'image_generate') {
return null
}
const record = recordFromUnknown(part.result)
return record && record.success !== false ? record : null
}
/** Display source for a completed `image_generate` result (host path wins). */
export function generatedImageFromResult(result: unknown): string | null {
const record = recordFromUnknown(result)
if (!record || record.success === false) {
return null
}
return stringFields(record, DISPLAY_KEYS)[0] ?? null
}
/** Every path/URL a generated image might appear as in prose, for de-duping. */
export function generatedImageEchoSources(parts: readonly ToolLike[]): string[] {
return unique(parts.flatMap(part => stringFields(imageResult(part) ?? {}, ECHO_KEYS)))
}
/** Strip a generated image out of prose so it only ever shows in the tool slot.
* Once a generation succeeded (`sources` is non-empty) we drop every embedded
* image and media link from that message — the model frequently restates the
* remote URL while the result holds the local path, so matching the exact
* source is not enough. Bare occurrences of the known paths/URLs are removed
* too. Surrounding prose is preserved. */
export function stripGeneratedImageEchoes(text: string, sources: readonly string[]): string {
if (!text || sources.length === 0) {
return text
}
let next = text.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '').replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
for (const source of unique([...sources])) {
next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
}
return next
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim()
}
/** Strip generated-image echoes from text parts, dropping any part left empty.
* The image lives in the tool slot; prose keeps the agent's actual words. */
export function dedupeGeneratedImageEchoesInParts<T extends TextLike & ToolLike>(parts: readonly T[]): T[] {
const sources = generatedImageEchoSources(parts)
if (!sources.length) {
return [...parts]
}
return parts
.map(part =>
part.type === 'text' && typeof part.text === 'string'
? { ...part, text: stripGeneratedImageEchoes(part.text, sources) }
: part
)
.filter(part => part.type !== 'text' || (typeof part.text === 'string' && part.text.trim().length > 0))
}
@@ -0,0 +1,90 @@
import { dismissNotification, notify, notifyError } from '@/store/notifications'
/** The gateway's model-switch handshake shape — shared by `config.set model`
* and `profiles.configure` (Bots editor). `confirm_required: true` means the
* switch was intentionally NOT applied and the gateway is waiting for a
* resend that carries `confirm_expensive_model: true`. */
export interface GuardedModelSwitchResult {
confirm_message?: string
confirm_required?: boolean
deferred?: boolean
}
export interface SurfaceModelSwitchConfirmOptions<T extends GuardedModelSwitchResult> {
/** Label for the confirm action button (and default title). */
confirmLabel: string
/** The gateway's `confirm_message`, when present. */
confirmMessage?: string
/** Error-toast copy when the confirmed resend still fails. */
failureMessage: string
/** Message when the gateway sent no `confirm_message`. */
fallbackMessage?: string
/** Runs after the confirmed resend succeeds (cache invalidation etc.). */
finish?: (result: T | undefined) => void
/** Staleness guard — the warning can linger while the user picks a
* different model or switches sessions. Return true to make Confirm a
* no-op (the notification is dismissed) instead of clobbering the newer
* choice. */
isStale?: () => boolean
/** Optimistically repaint the pending selection before the resend. */
repaint?: () => void
/** Resend the switch WITH `confirm_expensive_model: true`. */
requestConfirmed: () => Promise<T | undefined>
/** Undo the optimistic repaint when the confirmed resend fails. */
rollback?: () => void
title?: string
}
/**
* THE confirm flow for guarded model switches — every surface that can
* receive `confirm_required` from a model switch (core picker via
* `config.set`, Bots editor via `profiles.configure`, future surfaces) routes
* it through here so there is exactly one applier and no forked confirm
* logic per surface (#95293).
*
* Shows a warning notification whose Confirm action resends the switch with
* `confirm_expensive_model: true`, guarded against stale confirmations. A
* resend that STILL answers `confirm_required` is treated as a failure — the
* gateway asked twice, something is wrong; never loop.
*
* Returns the notification id.
*/
export function surfaceModelSwitchConfirm<T extends GuardedModelSwitchResult>(
options: SurfaceModelSwitchConfirmOptions<T>
): string {
const applyConfirmedSwitch = async () => {
if (options.isStale?.()) {
dismissNotification(notificationId)
return
}
dismissNotification(notificationId)
options.repaint?.()
try {
const result = await options.requestConfirmed()
if (result?.confirm_required) {
throw new Error(result.confirm_message?.trim() || options.failureMessage)
}
options.finish?.(result)
} catch (err) {
options.rollback?.()
notifyError(err, options.failureMessage)
}
}
const notificationId = notify({
action: {
label: options.confirmLabel,
onClick: applyConfirmedSwitch
},
kind: 'warning',
message: options.confirmMessage?.trim() || options.fallbackMessage || 'Confirm this model switch?',
title: options.title ?? options.confirmLabel
})
return notificationId
}
@@ -0,0 +1,36 @@
/**
* Electron <webview> guests (and iframes) hit-test in their own process, so a
* pointer-capture drag in the embedder goes silent the moment the cursor
* crosses one — a sash resize froze after a few pixels of travel into the
* in-app browser, and only another press could continue it. While a drag is
* live, make every guest surface transparent to hit-testing (see the
* `guest-pointer-lock` rule in styles.css) so the window-level pointermove /
* pointerup listeners keep receiving the gesture.
*/
let depth = 0
/** Suppress pointer events on webview/iframe guests until released. Depth-
* counted so overlapping gestures compose; the returned release is
* idempotent (drags end through several racing paths — pointerup, blur,
* lostpointercapture). */
export function guardGuestPointers(): () => void {
if (depth === 0) {
document.body.classList.add('guest-pointer-lock')
}
depth += 1
let released = false
return () => {
if (released) {
return
}
released = true
depth -= 1
if (depth === 0) {
document.body.classList.remove('guest-pointer-lock')
}
}
}
+129
View File
@@ -0,0 +1,129 @@
import type { HapticInput, TriggerOptions } from 'web-haptics'
import { $hapticsMuted } from '@/store/haptics'
export type HapticIntent =
| 'cancel'
| 'close'
| 'crisp'
| 'error'
| 'open'
| 'selection'
| 'streamDone'
| 'streamStart'
| 'submit'
| 'success'
| 'tap'
| 'warning'
interface HapticConfig {
options?: TriggerOptions
pattern: HapticInput
}
const airyTap = [{ duration: 16, intensity: 0.52 }]
const crispTap = [{ duration: 10, intensity: 0.92 }]
const friendlySuccess = [
{ duration: 28, intensity: 0.5 },
{ delay: 42, duration: 30, intensity: 0.68 },
{ delay: 48, duration: 38, intensity: 0.86 }
]
const softArrive = [
{ duration: 18, intensity: 0.42 },
{ delay: 36, duration: 22, intensity: 0.66 }
]
const softLeave = [
{ duration: 22, intensity: 0.58 },
{ delay: 32, duration: 16, intensity: 0.34 }
]
const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = {
cancel: {
pattern: [
{ duration: 34, intensity: 0.72 },
{ delay: 54, duration: 26, intensity: 0.38 }
]
},
close: { pattern: softLeave },
crisp: { pattern: crispTap },
error: {
pattern: [
{ duration: 34, intensity: 0.82 },
{ delay: 42, duration: 34, intensity: 0.72 },
{ delay: 58, duration: 44, intensity: 0.86 }
]
},
open: { pattern: softArrive },
selection: { pattern: airyTap },
streamDone: { pattern: friendlySuccess },
streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] },
submit: {
pattern: [
{ duration: 24, intensity: 0.58 },
{ delay: 48, duration: 36, intensity: 0.82 }
]
},
success: { pattern: friendlySuccess },
tap: {
pattern: [
{ duration: 14, intensity: 0.58 },
{ delay: 30, duration: 12, intensity: 0.42 }
]
},
warning: {
pattern: [
{ duration: 34, intensity: 0.64 },
{ delay: 84, duration: 42, intensity: 0.5 }
]
}
}
export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined
let registeredTrigger: HapticTrigger | null = null
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) {
registeredTrigger = trigger
}
export function triggerHaptic(intent: HapticIntent = 'selection') {
if ($hapticsMuted.get() || !registeredTrigger) {
return
}
const now = performance.now()
if (intent === 'selection') {
if (now - lastSelectionAt < 50) {
return
}
lastSelectionAt = now
}
recentFires = recentFires.filter(t => now - t < RATE_WINDOW)
if (recentFires.length >= RATE_LIMIT) {
return
}
recentFires.push(now)
const config = HAPTIC_INTENTS[intent]
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
normalizeHermesOpenString,
pathFromHermesDeepLink,
pathFromOpenDeepLink,
resolveHermesOpenPath
} from './hermes-open-target'
describe('normalizeHermesOpenString', () => {
it('accepts hash-router paths and strips a leading hash', () => {
expect(normalizeHermesOpenString('/index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('#/index-network/intent/1')).toBe('/index-network/intent/1')
})
it('maps plugin-scoped hermes:// deep links to the same path', () => {
expect(normalizeHermesOpenString('hermes://index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('hermes://index-network/intent/1?focus=true')).toBe(
'/index-network/intent/1?focus=true'
)
})
it('maps hermes://open/… deep links by stripping the open host', () => {
expect(normalizeHermesOpenString('hermes://open/index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('hermes://open/settings/plugins')).toBe('/settings/plugins')
})
it('rejects reserved hermes kinds and unsafe paths', () => {
expect(normalizeHermesOpenString('hermes://blueprint/morning-brief')).toBeNull()
expect(normalizeHermesOpenString('hermes://plugin/install')).toBeNull()
expect(normalizeHermesOpenString('https://example.com/x')).toBeNull()
expect(normalizeHermesOpenString('/../etc/passwd')).toBeNull()
expect(normalizeHermesOpenString('index-network')).toBeNull()
})
})
describe('resolveHermesOpenPath', () => {
it('merges structured path + params', () => {
expect(resolveHermesOpenPath({ path: '/index-network/intent/1', params: { focus: 'true' } })).toBe(
'/index-network/intent/1?focus=true'
)
})
it('resolves href the same as a bare string', () => {
expect(resolveHermesOpenPath({ href: 'hermes://index-network/intent/1' })).toBe('/index-network/intent/1')
})
})
describe('pathFromHermesDeepLink', () => {
it('builds the navigate path from a plugin-scoped deep-link payload', () => {
expect(pathFromHermesDeepLink('index-network', 'intent/1')).toBe('/index-network/intent/1')
})
it('builds the navigate path from hermes://open/… payloads', () => {
expect(pathFromOpenDeepLink('index-network/intent/1')).toBe('/index-network/intent/1')
expect(pathFromHermesDeepLink('open', 'agent/42')).toBe('/agent/42')
})
it('ignores reserved kinds', () => {
expect(pathFromHermesDeepLink('blueprint', 'morning-brief')).toBeNull()
expect(pathFromHermesDeepLink('plugin', 'install')).toBeNull()
})
})
+177
View File
@@ -0,0 +1,177 @@
/**
* Shared resolver for in-app navigation targets that must stay compatible with
* `hermes://` deep links and `host.navigate('/path?…')`.
*
* Notification activation, deep-link delivery, and plugin `activate` payloads
* all funnel through here so a toast click and an OS deep link land on the
* same hash-router path.
*
* Supported deep-link shapes:
* - `hermes://index-network/intent/1` → `/index-network/intent/1` (plugin-scoped)
* - `hermes://open/my-page?item=x` → `/my-page?item=x` (generic open)
* - `/my-page?item=x` / `#/my-page?item=x` (hash-router paths)
*/
export type HermesOpenTarget = string | { href: string } | { path: string; params?: Record<string, string> }
const HERMES_PROTOCOL = 'hermes:'
/** Hostnames owned by core deep-link handlers — never treated as plugin routes. */
const RESERVED_DEEP_LINK_KINDS = new Set([
'blueprint',
'chat',
'install',
'mcp',
'open',
'plugin',
'plugin-agent',
'plugin-desktop',
'settings'
])
function appendSearch(path: string, params: URLSearchParams | Record<string, string> | undefined): string {
if (!params) {
return path
}
const search =
params instanceof URLSearchParams
? params
: new URLSearchParams(Object.entries(params).filter(([, v]) => v != null && v !== ''))
const qs = search.toString()
if (!qs) {
return path
}
return path.includes('?') ? `${path}&${qs}` : `${path}?${qs}`
}
function isSafeAppPath(path: string): boolean {
if (!path.startsWith('/') || path.startsWith('//')) {
return false
}
// Block traversal and scheme smuggling in the path segment.
if (path.includes('..') || path.includes('\\') || path.includes(':')) {
return false
}
return true
}
function isPluginDeepLinkHost(host: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/.test(host) && !RESERVED_DEEP_LINK_KINDS.has(host)
}
/** Normalize a string target to a hash-router path, or null. */
export function normalizeHermesOpenString(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
if (trimmed.startsWith('hermes://') || trimmed.startsWith(`${HERMES_PROTOCOL}//`)) {
try {
const url = new URL(trimmed)
const host = url.hostname || ''
const rest = decodeURIComponent((url.pathname || '').replace(/^\//, ''))
// hermes://open/<path>?… → /<path>?…
if (host === 'open') {
if (!rest) {
return null
}
const path = `/${rest}`
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return appendSearch(path, url.searchParams)
}
// hermes://index-network/intent/1 → /index-network/intent/1
if (!isPluginDeepLinkHost(host) || !rest) {
return null
}
const path = `/${host}/${rest}`
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return appendSearch(path, url.searchParams)
} catch {
return null
}
}
const path = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return path
}
/** Resolve any supported activate/open target to a hash-router path, or null. */
export function resolveHermesOpenPath(target: HermesOpenTarget | null | undefined): string | null {
if (target == null) {
return null
}
if (typeof target === 'string') {
return normalizeHermesOpenString(target)
}
if (typeof target !== 'object') {
return null
}
if ('href' in target && typeof target.href === 'string') {
return normalizeHermesOpenString(target.href)
}
if ('path' in target && typeof target.path === 'string') {
const base = normalizeHermesOpenString(target.path)
if (!base) {
return null
}
return appendSearch(base, target.params)
}
return null
}
/**
* Build a navigate path from a parsed deep-link payload
* (`hermes://<kind>/<name>?…` → kind/name/params).
*/
export function pathFromHermesDeepLink(kind: string, name: string, params: Record<string, string> = {}): string | null {
if (!kind || !name) {
return null
}
if (kind === 'open') {
return resolveHermesOpenPath({ path: `/${name.replace(/^\//, '')}`, params })
}
if (!isPluginDeepLinkHost(kind)) {
return null
}
return resolveHermesOpenPath({ path: `/${kind}/${name.replace(/^\//, '')}`, params })
}
/** Convenience for `hermes://open/<name>?…` payloads. */
export function pathFromOpenDeepLink(name: string, params: Record<string, string> = {}): string | null {
return pathFromHermesDeepLink('open', name, params)
}
+279
View File
@@ -0,0 +1,279 @@
import {
IconActivity as Activity,
IconAlertCircle as AlertCircle,
IconAlertTriangle as AlertTriangle,
IconAppWindow as AppWindow,
IconArchive as Archive,
IconArchiveOff as ArchiveOff,
IconArrowUp as ArrowUp,
IconArrowUpRight as ArrowUpRight,
IconAt as AtSign,
IconWaveSine as AudioLines,
IconChartBar as BarChart3,
IconBell as Bell,
IconBookmark as Bookmark,
IconBookmarkFilled as BookmarkFilled,
IconBox as Box,
IconBrain as Brain,
IconBug as Bug,
IconCheck as Check,
IconCircleCheck as CheckCircle2,
IconCheck as CheckIcon,
IconChevronDown as ChevronDown,
IconChevronDown as ChevronDownIcon,
IconChevronLeft as ChevronLeft,
IconChevronLeft as ChevronLeftIcon,
IconChevronRight as ChevronRight,
IconChevronRight as ChevronRightIcon,
IconCircle as CircleIcon,
IconCircleLetterA as CircleLetterA,
IconClipboard as Clipboard,
IconClock as Clock,
IconCloud as Cloud,
IconCommand as Command,
IconCopy as Copy,
IconCopy as CopyIcon,
IconCornerDownLeft as CornerDownLeft,
IconCpu as Cpu,
IconCreditCard as CreditCard,
IconDownload as Download,
IconEar as Ear,
IconEarOff as EarOff,
IconEgg as Egg,
IconPlayerEjectFilled as Eject,
IconExternalLink as ExternalLink,
IconEye as Eye,
IconEyeOff as EyeOff,
IconPhoto as FileImage,
IconFileText as FileText,
IconFolderOpen as FolderOpen,
IconGitBranch as GitBranch,
IconGitBranch as GitBranchIcon,
IconGitFork as GitFork,
IconGitFork as GitForkIcon,
IconGlobe as Globe,
IconHash as Hash,
IconHelpCircle as HelpCircle,
IconPhoto as ImageIcon,
IconInfoCircle as Info,
IconKeyboard as Keyboard,
IconKey as KeyRound,
IconLayersIntersect2 as Layers3,
IconLayoutDashboard as LayoutDashboard,
IconLink as Link,
IconLink as Link2,
IconLink as LinkIcon,
IconLoader2 as Loader2,
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
IconMaximize as Maximize,
IconMessageCircle as MessageCircle,
IconMessageCode as MessageCode,
IconMessageQuestion as MessageQuestion,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
IconMicrophoneOff as MicOff,
IconDeviceDesktop as Monitor,
IconDeviceDesktopAnalytics as MonitorPlay,
IconMoon as Moon,
IconDots as MoreHorizontal,
IconDots as MoreHorizontalIcon,
IconDotsVertical as MoreVertical,
IconNetwork as Network,
IconNotebook as NotebookTabs,
IconPackage as Package,
IconPalette as Palette,
IconLayoutBottombar as PanelBottom,
IconLayoutSidebar as PanelLeftIcon,
IconLayoutNavbar as PanelTop,
IconPlayerPause as Pause,
IconPaw as PawPrint,
IconPencil as Pencil,
IconPencil as PencilIcon,
IconPencil as PencilLine,
IconPin as Pin,
IconPlayerPlay as Play,
IconPlus as Plus,
IconPower as Power,
IconRefresh as RefreshCw,
IconRefresh as RefreshCwIcon,
IconDeviceFloppy as Save,
IconSearch as Search,
IconSearch as SearchIcon,
IconSend as Send,
IconSettings as Settings,
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconMoodPlus as SmilePlusIcon,
IconSquare as Square,
IconChartDots3 as Starmap,
IconSteeringWheel as SteeringWheel,
IconPlayerStopFilled as StopFilled,
IconSun as Sun,
IconTerminal2 as Terminal,
IconTrash as Trash2,
IconUpload as Upload,
IconUsers as Users,
IconVolume2 as Volume2,
IconVolume2 as Volume2Icon,
IconVolumeOff as VolumeX,
IconVolumeOff as VolumeXIcon,
IconTool as Wrench,
IconX as X,
IconX as XIcon,
IconBolt as Zap,
IconBoltFilled as ZapFilled,
IconZoomIn as ZoomIn,
IconZoomOut as ZoomOut
} from '@tabler/icons-react'
export {
Activity,
AlertCircle,
AlertTriangle,
AppWindow,
Archive,
ArchiveOff,
ArrowUp,
ArrowUpRight,
AtSign,
AudioLines,
BarChart3,
Bell,
Bookmark,
BookmarkFilled,
Box,
Brain,
Bug,
Check,
CheckCircle2,
CheckIcon,
ChevronDown,
ChevronDownIcon,
ChevronLeft,
ChevronLeftIcon,
ChevronRight,
ChevronRightIcon,
CircleIcon,
CircleLetterA,
Clipboard,
Clock,
Cloud,
Command,
Copy,
CopyIcon,
CornerDownLeft,
Cpu,
CreditCard,
Download,
Ear,
EarOff,
Egg,
Eject,
ExternalLink,
Eye,
EyeOff,
FileImage,
FileText,
FolderOpen,
GitBranch,
GitBranchIcon,
GitFork,
GitForkIcon,
Globe,
Hash,
HelpCircle,
ImageIcon,
Info,
Keyboard,
KeyRound,
Layers3,
LayoutDashboard,
Link,
Link2,
LinkIcon,
Loader2,
Loader2Icon,
Lock,
LogIn,
Mail,
Maximize,
MessageCircle,
MessageCode,
MessageQuestion,
MessageSquareText,
Mic,
MicOff,
Monitor,
MonitorPlay,
Moon,
MoreHorizontal,
MoreHorizontalIcon,
MoreVertical,
Network,
NotebookTabs,
Package,
Palette,
PanelBottom,
PanelLeftIcon,
PanelTop,
Pause,
PawPrint,
Pencil,
PencilIcon,
PencilLine,
Pin,
Play,
Plus,
Power,
RefreshCw,
RefreshCwIcon,
Save,
Search,
SearchIcon,
Send,
Settings,
Settings2,
SlidersHorizontal,
SmilePlusIcon,
Square,
Starmap,
SteeringWheel,
StopFilled,
Sun,
Terminal,
Trash2,
Upload,
Users,
Volume2,
Volume2Icon,
VolumeX,
VolumeXIcon,
Wrench,
X,
XIcon,
Zap,
ZapFilled,
ZoomIn,
ZoomOut
}
export type { Icon as IconComponent } from '@tabler/icons-react'
/**
* Named icon-size scale — the single source of truth for SVG icon dimensions,
* replacing ad-hoc `h-N w-N` / `size={N}`. Use `<Icon className={iconSize.sm} />`
* (Tailwind `size-*` sets w+h and beats the icon's default 24px); compose with
* `cn()` for colour/animation classes.
*/
export const iconSize = {
xs: 'size-3', // 12px
sm: 'size-3.5', // 14px
md: 'size-4', // 16px
lg: 'size-5', // 20px
xl: 'size-6' // 24px
} as const
export type IconSize = keyof typeof iconSize
+199
View File
@@ -0,0 +1,199 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downscaleDataUrlForPreview } from './image-resize'
// A minimal valid 1x1 red PNG (67 bytes) for testing
const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
const TINY_PNG_DATA_URL = `data:image/png;base64,${TINY_PNG_B64}`
// A 1×1 transparent PNG used as the fallback placeholder
const FALLBACK_PLACEHOLDER =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
describe('downscaleDataUrlForPreview', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('without createImageBitmap (jsdom default)', () => {
it('fails closed when createImageBitmap is unavailable', async () => {
await expect(downscaleDataUrlForPreview('data:image/png;base64,ZmFrZQ==')).resolves.toBe(FALLBACK_PLACEHOLDER)
})
it('preserves an external source when resize APIs are unavailable', async () => {
await expect(downscaleDataUrlForPreview('not-a-data-url')).resolves.toBe('not-a-data-url')
})
it('preserves a non-image data URL when resize APIs are unavailable', async () => {
await expect(downscaleDataUrlForPreview('data:text/plain')).resolves.toBe('data:text/plain')
})
})
describe('with mocked createImageBitmap + OffscreenCanvas', () => {
/**
* Set up the full mock chain: fetch → createImageBitmap → OffscreenCanvas → FileReader.
* Mocks a bitmap of the given dimensions so the downscaling logic is exercised.
*/
function setupMocks(bitmapWidth: number, bitmapHeight: number) {
let activeBitmaps = 0
let maxActiveBitmaps = 0
const close = vi.fn(() => {
activeBitmaps -= 1
})
const drawImage = vi.fn()
const convertToBlob = vi.fn(async () => new Blob(['x'], { type: 'image/png' }))
const canvasSizes: [number, number][] = []
const bitmap = { width: bitmapWidth, height: bitmapHeight, close }
const ctx = { drawImage }
class MockOffscreenCanvas {
getContext = vi.fn(() => ctx)
convertToBlob = convertToBlob
constructor(width: number, height: number) {
canvasSizes.push([width, height])
}
}
// Mock fetch to return a Blob from the data URL
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => {
activeBitmaps += 1
maxActiveBitmaps = Math.max(maxActiveBitmaps, activeBitmaps)
return bitmap
})
)
vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas)
return { bitmap, close, drawImage, convertToBlob, ctx, canvasSizes, maxActiveBitmaps: () => maxActiveBitmaps }
}
it('returns the original when image is smaller than maxLongEdge', async () => {
setupMocks(500, 300)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(TINY_PNG_DATA_URL)
})
it('downscales when image exceeds the default preview edge', async () => {
const { drawImage, close } = setupMocks(4000, 3000)
// In jsdom, FileReader.readAsDataURL won't actually produce a data URL,
// so the function falls through to the fallback placeholder. But the
// important thing is that drawImage was called with scaled dimensions
// and the bitmap was closed.
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL)
// scale = 512 / 4000 = 0.128 → width=512, height=384
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 512, 384)
expect(close).toHaveBeenCalled()
// Result should be the downscaled data URL (from our mocked blob),
// NOT the original tiny PNG — the function attempted downscaling.
expect(result).not.toBe(TINY_PNG_DATA_URL)
// The drawImage was called with correct dimensions and bitmap was cleaned up
expect(drawImage).toHaveBeenCalled()
expect(close).toHaveBeenCalled()
})
it('keeps every thumbnail bounded for a 72-image composer prompt', async () => {
const { canvasSizes, drawImage, maxActiveBitmaps } = setupMocks(6000, 6000)
await Promise.all(Array.from({ length: 72 }, () => downscaleDataUrlForPreview(TINY_PNG_DATA_URL)))
expect(canvasSizes).toHaveLength(72)
expect(canvasSizes.every(([width, height]) => width === 512 && height === 512)).toBe(true)
expect(drawImage).toHaveBeenCalledTimes(72)
expect(maxActiveBitmaps()).toBe(1)
})
it('returns placeholder when createImageBitmap throws', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => {
throw new Error('decode failed')
})
)
vi.stubGlobal(
'OffscreenCanvas',
vi.fn(() => ({}))
)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
})
it('closes bitmap even when canvas getContext returns null', async () => {
const close = vi.fn()
const bitmap = { width: 4000, height: 3000, close }
class NullCanvas {
getContext = () => null
constructor(_w: number, _h: number) {}
}
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => bitmap)
)
vi.stubGlobal('OffscreenCanvas', NullCanvas)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
expect(close).toHaveBeenCalled()
})
it('uses placeholder instead of original on convertToBlob failure', async () => {
const close = vi.fn()
const bitmap = { width: 4000, height: 3000, close }
class BrokenCanvas {
getContext = () => ({ drawImage: vi.fn() })
convertToBlob = vi.fn(async () => {
throw new Error('blob failed')
})
constructor(_w: number, _h: number) {}
}
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => bitmap)
)
vi.stubGlobal('OffscreenCanvas', BrokenCanvas)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
expect(close).toHaveBeenCalled()
})
})
})
+95
View File
@@ -0,0 +1,95 @@
/**
* Downscale a data URL for preview rendering.
*
* Large images (Retina screenshots, 6000×4000+) cause Chromium to build very
* large paint operations when the original is assigned to an <img>. This
* utility uses createImageBitmap + OffscreenCanvas to resize before display.
*
* Calls share a one-at-a-time queue. Multi-image attach flows must not keep
* dozens of decoded full-resolution bitmaps alive concurrently while their
* 512px thumbnails are produced.
*
* @param dataUrl The full-resolution data URL (data:image/...;base64,...)
* @param maxLongEdge Maximum pixel dimension on the longest side (default 512)
* @returns A downscaled PNG data URL, the original if already small enough, or
* a 1×1 transparent PNG placeholder if downscaling fails.
*/
/** 1×1 transparent PNG — fail closed so the pill never receives the original. */
const FALLBACK_PLACEHOLDER =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
// Composer pills render at 32 CSS pixels. A 512px source stays sharp on
// high-density displays while keeping a square RGBA decode to 1 MiB. A 2048px
// thumbnail can decode to 16 MiB and reproduce Chromium's paint-op pressure.
const DEFAULT_MAX_LONG_EDGE = 512
let resizeQueue = Promise.resolve()
async function downscaleDataUrl(dataUrl: string, maxLongEdge: number): Promise<string> {
try {
// fetch(data:) decodes base64 natively in C++ and avoids an O(n) atob loop.
const blob = await fetch(dataUrl).then(response => response.blob())
const bitmap = await createImageBitmap(blob)
try {
const { width, height } = bitmap
const longEdge = Math.max(width, height)
if (longEdge <= maxLongEdge) {
return dataUrl
}
const scale = maxLongEdge / longEdge
const newWidth = Math.max(1, Math.round(width * scale))
const newHeight = Math.max(1, Math.round(height * scale))
const canvas = new OffscreenCanvas(newWidth, newHeight)
const ctx = canvas.getContext('2d')
if (!ctx) {
return FALLBACK_PLACEHOLDER
}
ctx.drawImage(bitmap, 0, 0, newWidth, newHeight)
const resultBlob = await canvas.convertToBlob({ type: 'image/png' })
const reader = new FileReader()
return await new Promise<string>(resolve => {
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : FALLBACK_PLACEHOLDER)
reader.onabort = () => resolve(FALLBACK_PLACEHOLDER)
reader.onerror = () => resolve(FALLBACK_PLACEHOLDER)
reader.readAsDataURL(resultBlob)
})
} finally {
bitmap.close()
}
} catch {
return FALLBACK_PLACEHOLDER
}
}
export async function downscaleDataUrlForPreview(
dataUrl: string,
maxLongEdge = DEFAULT_MAX_LONG_EDGE
): Promise<string> {
if (!dataUrl.startsWith('data:image/') || dataUrl.indexOf(',') === -1) {
return dataUrl
}
// Never hand a full-resolution image to the pill if resize support is absent.
// A tiny placeholder is preferable to recreating the renderer failure this
// helper exists to prevent.
if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function') {
return FALLBACK_PLACEHOLDER
}
const task = resizeQueue.then(() => downscaleDataUrl(dataUrl, maxLongEdge))
resizeQueue = task.then(
() => undefined,
() => undefined
)
return task
}
@@ -0,0 +1,144 @@
import { fromThreadMessageLike, getAutoStatus, MessageRepository } from '@assistant-ui/core/internal'
import type { ExportedMessageRepository, ThreadMessage } from '@assistant-ui/react'
import { describe, expect, it, vi } from 'vitest'
import { syncRepositoryIncrementally } from './incremental-external-store-runtime'
const STATUS = getAutoStatus(false, false, false, false, undefined)
function message(id: string, text: string): ThreadMessage {
return fromThreadMessageLike({ role: 'assistant', content: [{ type: 'text', text }] }, id, STATUS)
}
/** A real MessageRepository behind the same shape syncRepositoryIncrementally drives. */
function runtimeWith(items: { message: ThreadMessage; parentId: string | null }[]) {
const repository = new MessageRepository()
for (const { message: item, parentId } of items) {
repository.addOrUpdateMessage(parentId, item)
}
if (items.length > 0) {
repository.resetHead(items.at(-1)?.message.id ?? null)
}
return { repository } as unknown as Parameters<typeof syncRepositoryIncrementally>[0]
}
function chain(messages: ThreadMessage[]) {
return messages.map((item, index) => ({
message: item,
parentId: index === 0 ? null : messages[index - 1].id
}))
}
function exported(items: { message: ThreadMessage; parentId: string | null }[]): ExportedMessageRepository {
return { headId: items.at(-1)?.message.id ?? null, messages: items }
}
describe('syncRepositoryIncrementally', () => {
it('writes only the changed tail instead of the whole transcript', () => {
const settled = Array.from({ length: 200 }, (_, index) => message(`m-${index}`, `body ${index}`))
const items = chain(settled)
const runtime = runtimeWith(items)
const repository = (runtime as unknown as { repository: MessageRepository }).repository
const addOrUpdate = vi.spyOn(repository, 'addOrUpdateMessage')
const resetHead = vi.spyOn(repository, 'resetHead')
// One streamed delta: the tail grows, every settled message keeps identity.
const nextTail = message('m-199', 'body 199 + delta')
const nextItems = [...items.slice(0, -1), { message: nextTail, parentId: 'm-198' }]
const result = syncRepositoryIncrementally(runtime, exported(nextItems))
expect(addOrUpdate).toHaveBeenCalledTimes(1)
expect(addOrUpdate).toHaveBeenCalledWith('m-198', nextTail)
// The head did not move, so the descendant-pruning reset is skipped.
expect(resetHead).not.toHaveBeenCalled()
expect(result).toHaveLength(200)
expect(result.at(-1)).toBe(nextTail)
})
it('does nothing at all when the transcript is unchanged', () => {
const items = chain([message('a', 'one'), message('b', 'two')])
const runtime = runtimeWith(items)
const repository = (runtime as unknown as { repository: MessageRepository }).repository
const addOrUpdate = vi.spyOn(repository, 'addOrUpdateMessage')
const deleteMessage = vi.spyOn(repository, 'deleteMessage')
syncRepositoryIncrementally(runtime, exported(items))
expect(addOrUpdate).not.toHaveBeenCalled()
expect(deleteMessage).not.toHaveBeenCalled()
})
it('appends a new message through the full path', () => {
const first = message('a', 'one')
const items = chain([first])
const runtime = runtimeWith(items)
const second = message('b', 'two')
const result = syncRepositoryIncrementally(runtime, exported(chain([first, second])))
expect(result.map(item => item.id)).toEqual(['a', 'b'])
})
it('honours an authoritative deletion', () => {
const a = message('a', 'one')
const b = message('b', 'two')
const c = message('c', 'three')
const runtime = runtimeWith(chain([a, b, c]))
const result = syncRepositoryIncrementally(runtime, exported(chain([a, b])))
expect(result.map(item => item.id)).toEqual(['a', 'b'])
})
it('rebuilds cleanly when a disjoint transcript is swapped in', () => {
const runtime = runtimeWith(chain([message('old-1', 'one'), message('old-2', 'two')]))
const next = chain([message('new-1', 'alpha'), message('new-2', 'beta')])
const result = syncRepositoryIncrementally(runtime, exported(next))
expect(result.map(item => item.id)).toEqual(['new-1', 'new-2'])
})
it('re-parents a message when its branch parent changes', () => {
const root = message('root', 'root')
const a = message('a', 'a')
const b = message('b', 'b')
const runtime = runtimeWith([
{ message: root, parentId: null },
{ message: a, parentId: 'root' },
{ message: b, parentId: 'a' }
])
// Same ids and same message objects, but `b` moves onto a sibling branch.
const result = syncRepositoryIncrementally(runtime, {
headId: 'b',
messages: [
{ message: root, parentId: null },
{ message: a, parentId: 'root' },
{ message: b, parentId: 'root' }
]
})
expect(result.map(item => item.id)).toEqual(['root', 'b'])
})
it('moves the head when an explicit headId rewinds the branch', () => {
const a = message('a', 'one')
const b = message('b', 'two')
const runtime = runtimeWith(chain([a, b]))
const result = syncRepositoryIncrementally(runtime, {
headId: 'a',
messages: chain([a, b])
})
expect(result.map(item => item.id)).toEqual(['a'])
})
})
@@ -0,0 +1,284 @@
import {
AssistantRuntimeImpl,
BaseAssistantRuntimeCore,
ExternalStoreThreadListRuntimeCore,
ExternalStoreThreadRuntimeCore,
hasUpcomingMessage
} from '@assistant-ui/core/internal'
import {
type AssistantRuntime,
type ExternalStoreAdapter,
fromThreadMessageLike,
generateId,
type ThreadMessage,
useRuntimeAdapters
} from '@assistant-ui/react'
import { useEffect, useMemo, useState } from 'react'
const EMPTY_ARRAY = Object.freeze([])
const shallowEqual = (a: object, b: object): boolean => {
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) {
return false
}
for (const key of aKeys) {
if (a[key as keyof typeof a] !== b[key as keyof typeof b]) {
return false
}
}
return true
}
const getThreadListAdapter = (store: ExternalStoreAdapter) => store.adapters?.threadList ?? {}
/**
* Write only the items whose (message, parentId) pair actually moved.
*
* `useRuntimeMessageRepository` caches normalized ThreadMessages by source
* identity, so a settled turn keeps the SAME object across renders. That makes
* an identity check a sound "did this change?" test: during streaming exactly
* one item — the growing tail — differs, and the other N-1 writes were pure
* overhead that grew with transcript length.
*
* Returns false when the export is stale (an id in `existing` is gone, or an
* incoming message has no repository entry yet), so the caller falls back to
* the full rebuild rather than guessing.
*/
function applyChangedMessages(
repository: ExternalStoreThreadRuntimeCore['repository'],
existing: readonly { message: ThreadMessage; parentId: string | null }[],
incoming: readonly { message: ThreadMessage; parentId: string | null }[]
): boolean {
if (existing.length !== incoming.length) {
return false
}
const existingById = new Map(existing.map(item => [item.message.id, item]))
for (const item of incoming) {
const current = existingById.get(item.message.id)
if (!current) {
return false
}
// Reference identity, not deep equality: the conversion cache guarantees a
// stable object for an unchanged turn, and a changed turn is a new object.
if (current.message !== item.message || current.parentId !== item.parentId) {
repository.addOrUpdateMessage(item.parentId, item.message)
}
}
return true
}
export function syncRepositoryIncrementally(
runtime: ExternalStoreThreadRuntimeCore,
messageRepository: NonNullable<ExternalStoreAdapter['messageRepository']>
): readonly ThreadMessage[] {
const repository = (runtime as unknown as { repository: ExternalStoreThreadRuntimeCore['repository'] }).repository
const incoming = messageRepository.messages
const existing = repository.export().messages
const headId = messageRepository.headId ?? incoming.at(-1)?.message.id ?? null
// A thread switch swaps in a fully-DISJOINT transcript (no id carries over).
// Reconciling two unrelated trees in place — grafting the new chain onto the
// old one, then pruning — can strand a stale head/branch, so there's nothing
// to preserve: clear the tree first (leaves→root), then rebuild clean.
const incomingIds = new Set(incoming.map(({ message }) => message.id))
const disjoint = existing.length > 0 && !existing.some(({ message }) => incomingIds.has(message.id))
// Steady-state streaming: same message set, one item changed. Skip the
// whole-transcript rewrite, the prune scan, and the second export. resetHead
// deletes the head's descendants, so it only runs when the head really moved.
if (!disjoint && applyChangedMessages(repository, existing, incoming)) {
if (repository.headId !== headId) {
repository.resetHead(headId)
}
return repository.getMessages()
}
if (disjoint) {
for (const { message } of [...existing].reverse()) {
repository.deleteMessage(message.id)
}
}
for (const { message, parentId } of incoming) {
repository.addOrUpdateMessage(parentId, message)
}
for (const { message } of repository.export().messages) {
if (!incomingIds.has(message.id)) {
repository.deleteMessage(message.id)
}
}
repository.resetHead(headId)
return repository.getMessages()
}
class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRuntimeCore {
override __internal_setAdapter(store: ExternalStoreAdapter): void {
if (!store.messageRepository) {
super.__internal_setAdapter(store)
return
}
const self = this as unknown as {
_assistantOptimisticId: null | string
_capabilities: object
_messages: readonly ThreadMessage[]
_notifyEventSubscribers: (event: string, payload: object) => void
_notifySubscribers: () => void
_store?: ExternalStoreAdapter
}
if (self._store === store) {
return
}
const isRunning = store.isRunning ?? false
const newDisabled = store.isDisabled ?? false
const disabledChanged = this.isDisabled !== newDisabled
this.isDisabled = newDisabled
const oldStore = self._store
self._store = store
// Track whether anything OBSERVABLE changed. ChatRuntimeBoundary passes a
// fresh adapter literal on every render, so identity churn of the adapter
// object itself is NOT a change — notifying on it lets a subscriber whose
// notification re-renders the boundary drive an unbounded feedback loop
// (render -> new literal -> setAdapter -> notify -> render), which React
// kills with "Maximum update depth exceeded" and takes the session tile
// down with its error boundary.
let changed = disabledChanged
if (this.extras !== store.extras) {
this.extras = store.extras
changed = true
}
const newSuggestions = store.suggestions ?? EMPTY_ARRAY
if (!shallowEqual(this.suggestions, newSuggestions)) {
this.suggestions = newSuggestions
changed = true
}
const newCapabilities = {
switchToBranch: store.setMessages !== undefined,
switchBranchDuringRun: false,
edit: store.onEdit !== undefined,
reload: store.onReload !== undefined,
cancel: store.onCancel !== undefined,
speech: store.adapters?.speech !== undefined,
dictation: store.adapters?.dictation !== undefined,
voice: store.adapters?.voice !== undefined,
unstable_copy: store.unstable_capabilities?.copy !== false,
attachments: !!store.adapters?.attachments,
feedback: !!store.adapters?.feedback,
queue: false
}
if (!shallowEqual(self._capabilities, newCapabilities)) {
self._capabilities = newCapabilities
changed = true
}
if (oldStore && oldStore.isRunning === store.isRunning && oldStore.messageRepository === store.messageRepository) {
// Same transcript, same run state: notify only if extras/suggestions/
// capabilities actually moved. A silent no-op swap here is what breaks
// the render feedback loop — see the render-loop guard test.
if (changed) {
self._notifySubscribers()
}
return
}
if (self._assistantOptimisticId) {
this.repository.deleteMessage(self._assistantOptimisticId)
self._assistantOptimisticId = null
}
const messages = syncRepositoryIncrementally(this, store.messageRepository)
if (messages.length > 0) {
this.ensureInitialized()
}
if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
}
// metadata.isOptimistic keeps this placeholder ephemeral: core evicts
// off-branch optimistic messages on head moves and omits them from export().
if (hasUpcomingMessage(isRunning, messages)) {
const optimisticId = generateId()
this.repository.addOrUpdateMessage(
messages.at(-1)?.id ?? null,
fromThreadMessageLike({ role: 'assistant', content: [], metadata: { isOptimistic: true } }, optimisticId, {
type: 'running'
})
)
self._assistantOptimisticId = optimisticId
}
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)
self._messages = this.repository.getMessages()
self._notifySubscribers()
}
}
export class IncrementalExternalStoreRuntimeCore extends BaseAssistantRuntimeCore {
threads: ExternalStoreThreadListRuntimeCore
constructor(adapter: ExternalStoreAdapter) {
super()
this.threads = new ExternalStoreThreadListRuntimeCore(
getThreadListAdapter(adapter),
() => new IncrementalExternalStoreThreadRuntimeCore(this._contextProvider, adapter)
)
}
setAdapter(adapter: ExternalStoreAdapter): void {
this.threads.__internal_setAdapter(getThreadListAdapter(adapter))
this.threads.getMainThreadRuntimeCore().__internal_setAdapter(adapter)
}
}
export function useIncrementalExternalStoreRuntime<T extends ThreadMessage>(
store: ExternalStoreAdapter<T>
): AssistantRuntime {
const [runtime] = useState(() => new IncrementalExternalStoreRuntimeCore(store as ExternalStoreAdapter))
// Re-sync the adapter only when it actually changes — a dep-less effect ran
// on EVERY render of the chat surface. `__internal_setAdapter` early-exits
// when the store is unchanged, so gating on [runtime, store] is behavior-
// preserving while skipping the per-render call entirely.
useEffect(() => {
runtime.setAdapter(store as ExternalStoreAdapter)
}, [runtime, store])
const { modelContext } = useRuntimeAdapters() ?? {}
useEffect(() => {
if (!modelContext) {
return undefined
}
return runtime.registerModelContextProvider(modelContext)
}, [modelContext, runtime])
return useMemo(() => new AssistantRuntimeImpl(runtime), [runtime])
}
@@ -0,0 +1,170 @@
/**
* Regression tests for the bot-chat tile crash observed live on 2026-08-23:
*
* [error-boundary:contrib:session-tile:20260823_213059_ed6222]
* Error: Maximum update depth exceeded. The result of getSnapshot should be
* cached to avoid an infinite loop.
* at UseTapEffects (assistant-ui internal)
* at AuiProvider
* at ChatRuntimeBoundary (src/app/chat/index.tsx)
* at TileChat / SessionTilePane (src/app/chat/session-tile.tsx)
*
* Mechanism under test: ChatRuntimeBoundary passes a FRESH adapter object
* literal to useIncrementalExternalStoreRuntime on every render. The
* [runtime, store] effect therefore re-runs each render, and
* IncrementalExternalStoreThreadRuntimeCore.__internal_setAdapter's
* "nothing changed" fast path (same isRunning + same messageRepository)
* still calls _notifySubscribers() unconditionally. A subscriber whose
* notification re-renders the boundary then produces:
* render -> new store literal -> effect -> setAdapter -> notify -> render
* an unbounded feedback loop that trips React's update-depth guard and
* takes the whole session tile down with the error boundary.
*
* The invariant pinned here: handing __internal_setAdapter an adapter that is
* a NEW OBJECT but semantically identical (same messageRepository identity,
* same isRunning, same capabilities) must NOT notify subscribers. Notify is
* for observable state changes, not for object-identity churn of the adapter
* literal — the render loop is impossible once no-op swaps are silent.
*/
import { fromThreadMessageLike, getAutoStatus } from '@assistant-ui/core/internal'
import type { ExportedMessageRepository, ExternalStoreAdapter, ThreadMessage } from '@assistant-ui/react'
import { describe, expect, it } from 'vitest'
import { IncrementalExternalStoreRuntimeCore } from './incremental-external-store-runtime'
const STATUS = getAutoStatus(false, false, false, false, undefined)
function message(id: string, text: string): ThreadMessage {
return fromThreadMessageLike({ role: 'assistant', content: [{ type: 'text', text }] }, id, STATUS)
}
function repositoryOf(messages: ThreadMessage[]): ExportedMessageRepository {
return {
headId: messages.at(-1)?.id ?? null,
messages: messages.map((item, index) => ({
message: item,
parentId: index === 0 ? null : messages[index - 1].id
}))
}
}
function adapterWith(messageRepository: ExportedMessageRepository, extra: Partial<ExternalStoreAdapter> = {}) {
// Deliberately a fresh object each call — mirrors the inline literal in
// ChatRuntimeBoundary (src/app/chat/index.tsx) whose closures (onNew,
// onCancel, ...) are re-created per render.
return {
messageRepository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onCancel: async () => {},
...extra
} as ExternalStoreAdapter
}
describe('IncrementalExternalStoreThreadRuntimeCore adapter swap notifications', () => {
it('does NOT notify subscribers when a new adapter object carries identical state (render-loop guard)', () => {
const repo = repositoryOf([message('a', 'one'), message('b', 'two')])
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
const thread = core.threads.getMainThreadRuntimeCore()
let notifications = 0
thread.subscribe(() => {
notifications += 1
})
// Simulate 5 renders of ChatRuntimeBoundary: each passes a NEW adapter
// literal with the SAME messageRepository identity and same isRunning.
for (let render = 0; render < 5; render += 1) {
core.setAdapter(adapterWith(repo))
}
// The live bug: this was 5 — one notify per render, each notify able to
// schedule the next render. Silent no-op swaps break the feedback loop.
expect(notifications).toBe(0)
})
it('still notifies when the message repository actually changes', () => {
const repo = repositoryOf([message('a', 'one')])
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
const thread = core.threads.getMainThreadRuntimeCore()
let notifications = 0
thread.subscribe(() => {
notifications += 1
})
const grown = repositoryOf([message('a', 'one'), message('b', 'two')])
core.setAdapter(adapterWith(grown))
expect(notifications).toBeGreaterThan(0)
})
it('still notifies on an isRunning flip (turn start/stop must reach the thread UI)', () => {
const repo = repositoryOf([message('a', 'one')])
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
const thread = core.threads.getMainThreadRuntimeCore()
let notifications = 0
thread.subscribe(() => {
notifications += 1
})
core.setAdapter(adapterWith(repo, { isRunning: true }))
expect(notifications).toBeGreaterThan(0)
})
it('still notifies on an isDisabled flip even when transcript and run state are unchanged', () => {
const repo = repositoryOf([message('a', 'one')])
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
const thread = core.threads.getMainThreadRuntimeCore()
let notifications = 0
thread.subscribe(() => {
notifications += 1
})
core.setAdapter(adapterWith(repo, { isDisabled: true }))
expect(notifications).toBeGreaterThan(0)
// And flipping back also notifies — but an unchanged repeat stays silent.
const beforeFlipBack = notifications
core.setAdapter(adapterWith(repo, { isDisabled: false }))
expect(notifications).toBeGreaterThan(beforeFlipBack)
const afterFlipBack = notifications
core.setAdapter(adapterWith(repo, { isDisabled: false }))
expect(notifications).toBe(afterFlipBack)
})
it('a subscriber that swaps a fresh-but-identical adapter on every notify must not recurse unboundedly', () => {
// Direct simulation of the feedback loop: the subscriber plays the role of
// React re-rendering ChatRuntimeBoundary (new literal -> setAdapter). With
// the bug, every setAdapter notifies, the subscriber re-enters setAdapter,
// and only the depth guard stops it. Fixed behavior: the first no-op swap
// is silent, so the subscriber never fires and depth stays 0.
const repo = repositoryOf([message('a', 'one')])
const core = new IncrementalExternalStoreRuntimeCore(adapterWith(repo))
const thread = core.threads.getMainThreadRuntimeCore()
let depth = 0
thread.subscribe(() => {
depth += 1
if (depth > 25) {
throw new Error('Maximum update depth exceeded (simulated): adapter no-op swaps are notifying subscribers')
}
core.setAdapter(adapterWith(repo))
})
core.setAdapter(adapterWith(repo))
expect(depth).toBe(0)
})
})
@@ -0,0 +1,812 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
clearInFlightTurnJournal,
type JournalableSessionState,
mergeInFlightMessages,
persistInFlightTurnState,
readInFlightTurnJournal,
recoverInFlightTurnJournal,
resetInFlightTurnJournalStateForTests
} from '@/lib/inflight-turn-journal'
const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
const MIGRATION_KEY = 'hermes.desktop.inflightTurnJournal.v2.migrated'
const sessionStorageKey = (storedSessionId: string) => `${STORAGE_PREFIX}${encodeURIComponent(storedSessionId)}`
function user(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistant(id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }], ...extra }
}
function assistantWithTool(id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage {
return {
id,
role: 'assistant',
parts: [
{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'terminal', args: { command: 'ls' } },
{ type: 'text', text }
],
...extra
}
}
function journalState(overrides: Partial<JournalableSessionState> = {}): JournalableSessionState {
return {
awaitingResponse: false,
busy: true,
messages: [user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial answer', { pending: true })],
storedSessionId: 'stored-1',
streamId: 'assistant-stream-1',
turnStartedAt: 1000,
...overrides
}
}
beforeEach(() => {
resetInFlightTurnJournalStateForTests()
vi.useFakeTimers()
window.localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
clearInFlightTurnJournal('stored-1')
vi.useRealTimers()
})
describe('persistInFlightTurnState', () => {
it('sweeps expired and oldest session entries once before the first write', () => {
const now = Date.now()
for (let index = 0; index < 25; index += 1) {
const sessionId = `old-${index}`
const snapshot = {
messages: [
user(`u-${index}`, `prompt-${index}`),
assistant(`a-${index}`, `partial-${index}`, { pending: true })
],
streamId: `a-${index}`,
turnStartedAt: index,
updatedAt: now - index * 1_000
}
window.localStorage.setItem(sessionStorageKey(sessionId), JSON.stringify(snapshot))
}
window.localStorage.setItem(
sessionStorageKey('expired'),
JSON.stringify({
messages: [user('expired-u', 'expired'), assistant('expired-a', 'expired', { pending: true })],
streamId: 'expired-a',
turnStartedAt: 0,
updatedAt: now - 8 * 24 * 60 * 60 * 1_000
})
)
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
const sessionKeys = Array.from({ length: window.localStorage.length }, (_, index) =>
window.localStorage.key(index)
).filter((key): key is string => key?.startsWith(STORAGE_PREFIX) === true)
expect(sessionKeys).toHaveLength(24)
expect(window.localStorage.getItem(sessionStorageKey('expired'))).toBeNull()
expect(window.localStorage.getItem(sessionStorageKey('old-24'))).toBeNull()
expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).not.toBeNull()
})
it('writes only the current session instead of reading and rewriting the aggregate journal', () => {
const localStorage = window.localStorage
const storageConstructor = window.Storage
const spyTarget =
typeof storageConstructor === 'function' && localStorage instanceof storageConstructor
? storageConstructor.prototype
: localStorage
const getItem = vi.spyOn(spyTarget, 'getItem')
const setItem = vi.spyOn(spyTarget, 'setItem')
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
expect(getItem).not.toHaveBeenCalledWith(STORAGE_KEY)
expect(setItem).not.toHaveBeenCalledWith(STORAGE_KEY, expect.any(String))
expect(setItem).toHaveBeenCalledWith(sessionStorageKey('stored-1'), expect.any(String))
})
it('keeps another session snapshot when one session settles', () => {
persistInFlightTurnState(journalState())
persistInFlightTurnState(journalState({ storedSessionId: 'stored-2' }))
vi.advanceTimersByTime(400)
expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).not.toBeNull()
expect(window.localStorage.getItem(sessionStorageKey('stored-2'))).not.toBeNull()
clearInFlightTurnJournal('stored-2')
expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
expect(readInFlightTurnJournal('stored-2')).toBeNull()
})
it('journals the running turn tail after the throttle window', () => {
persistInFlightTurnState(journalState())
expect(readInFlightTurnJournal('stored-1')).toBeNull()
vi.advanceTimersByTime(400)
const entry = readInFlightTurnJournal('stored-1')
expect(entry).not.toBeNull()
expect(entry?.streamId).toBe('assistant-stream-1')
expect(entry?.turnStartedAt).toBe(1000)
expect(entry?.messages.map(m => m.role)).toEqual(['user', 'assistant'])
})
it('coalesces rapid updates into one write carrying the latest state', () => {
persistInFlightTurnState(journalState())
persistInFlightTurnState(
journalState({
messages: [
user('u1', 'do the thing'),
assistant('assistant-stream-1', 'partial answer grew', { pending: true })
]
})
)
vi.advanceTimersByTime(400)
const entry = readInFlightTurnJournal('stored-1')
const tail = entry?.messages.find(m => m.role === 'assistant')
expect(tail?.parts).toEqual([{ type: 'text', text: 'partial answer grew' }])
})
it('preserves a long user prompt exactly so recovery still matches its transcript row', () => {
const prompt = 'prompt '.repeat(8_000)
persistInFlightTurnState(
journalState({
messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
})
)
vi.advanceTimersByTime(400)
const result = recoverInFlightTurnJournal('stored-1', [user('db-u1', prompt)])
expect(result.messages.map(message => message.id)).toEqual(['db-u1', 'assistant-stream-1'])
})
it('preserves user attachment refs exactly so recovery still matches its transcript row', () => {
const attachmentRefs = Array.from({ length: 25 }, (_, index) => `@file:/tmp/input-${index}.txt`)
const prompt = user('u1', 'inspect these files')
prompt.attachmentRefs = attachmentRefs
persistInFlightTurnState(
journalState({ messages: [prompt, assistant('assistant-stream-1', 'partial', { pending: true })] })
)
vi.advanceTimersByTime(400)
const restoredPrompt = user('db-u1', 'inspect these files')
restoredPrompt.attachmentRefs = attachmentRefs
const result = recoverInFlightTurnJournal('stored-1', [restoredPrompt])
expect(result.messages.map(message => message.id)).toEqual(['db-u1', 'assistant-stream-1'])
})
it('trims oldest sealed rows when bounded parts exceed the entry cap', () => {
const text = 'x'.repeat(60 * 1024)
const messages = [
user('u1', 'do the thing'),
assistant('a1', text, { pending: false }),
assistant('a2', text, { pending: false }),
assistant('a3', text, { pending: false }),
assistant('a4', text, { pending: true })
]
persistInFlightTurnState(journalState({ messages, streamId: 'a4' }))
vi.advanceTimersByTime(400)
const raw = window.localStorage.getItem(sessionStorageKey('stored-1'))
const snapshot = JSON.parse(raw!)
expect(raw?.length).toBeLessThanOrEqual(160 * 1024)
expect(snapshot.messages.map((message: ChatMessage) => message.id)).toEqual(['u1', 'a3', 'a4'])
})
it('keeps an older recoverable snapshot when the newest row alone is too large', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
const hugeAssistant: ChatMessage = {
id: 'assistant-stream-1',
role: 'assistant',
parts: Array.from({ length: 3 }, () => ({ type: 'text' as const, text: 'x'.repeat(64 * 1024) })),
pending: true
}
persistInFlightTurnState(
journalState({ messages: [user('u1', 'do the thing'), hugeAssistant], streamId: hugeAssistant.id })
)
vi.advanceTimersByTime(400)
const snapshot = JSON.parse(window.localStorage.getItem(sessionStorageKey('stored-1'))!)
expect(snapshot.messages[1].parts).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('skips a pathological user prompt instead of truncating its recovery join key', () => {
const prompt = 'x'.repeat(64 * 1024 + 1)
persistInFlightTurnState(
journalState({
messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
})
)
vi.advanceTimersByTime(400)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('does not parse the legacy aggregate when a pathological write discards its session', () => {
const legacy = {
messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
streamId: 'legacy-a1',
turnStartedAt: 1,
updatedAt: Date.now()
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { 'stored-1': legacy }, version: 1 }))
const getItem = vi.spyOn(Storage.prototype, 'getItem')
const prompt = 'x'.repeat(64 * 1024 + 1)
persistInFlightTurnState(
journalState({
messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
})
)
vi.advanceTimersByTime(400)
expect(getItem).not.toHaveBeenCalledWith(STORAGE_KEY)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('preserves a tombstone while sweeping before legacy migration', () => {
const legacy = {
messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
streamId: 'legacy-a1',
turnStartedAt: 1,
updatedAt: Date.now()
}
window.localStorage.setItem(sessionStorageKey('stored-1'), '0')
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { 'stored-1': legacy }, version: 1 }))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).toBeNull()
})
it('removes tombstones after the one-shot legacy migration has completed', () => {
const key = sessionStorageKey('stored-1')
window.localStorage.setItem(MIGRATION_KEY, '1')
window.localStorage.setItem(key, '0')
expect(readInFlightTurnJournal('stored-1')).toBeNull()
expect(window.localStorage.getItem(key)).toBeNull()
})
it('strips pathological 5 MiB tool payloads before attempting a storage write', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem')
const oversized: ChatMessage = {
id: 'assistant-stream-1',
role: 'assistant',
parts: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'terminal',
args: { command: 'x'.repeat(5 * 1024 * 1024) },
result: 'x'.repeat(5 * 1024 * 1024),
isError: true
},
{ type: 'text', text: 'still useful' }
],
pending: true
}
persistInFlightTurnState(journalState({ messages: [user('u1', 'do the thing'), oversized] }))
vi.advanceTimersByTime(400)
const raw = window.localStorage.getItem(sessionStorageKey('stored-1'))
const snapshot = JSON.parse(raw!)
const persistedTool = snapshot.messages[1].parts[0]
expect(raw?.length).toBeLessThan(256 * 1024)
expect(persistedTool).toEqual({
args: {},
isError: true,
result: {},
toolCallId: 'tc-1',
toolName: 'terminal',
type: 'tool-call'
})
expect(snapshot.messages[1].parts[1]).toEqual({ type: 'text', text: 'still useful' })
expect(setItem.mock.calls.every(([, value]) => value.length <= 256 * 1024)).toBe(true)
})
it('clears the entry the moment the turn settles, cancelling pending writes', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
persistInFlightTurnState(journalState({ messages: [] }))
persistInFlightTurnState(journalState({ busy: false, awaitingResponse: false, streamId: null }))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
vi.advanceTimersByTime(1000)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('does not journal a turn with no recoverable assistant content yet', () => {
persistInFlightTurnState(journalState({ messages: [user('u1', 'do the thing')], streamId: null }))
vi.advanceTimersByTime(400)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('expires entries older than the max age', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
const key = sessionStorageKey('stored-1')
const raw = JSON.parse(window.localStorage.getItem(key)!)
raw.updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000
window.localStorage.setItem(key, JSON.stringify(raw))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('isolates storage read, write, and removal failures', () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('read denied')
})
expect(() => readInFlightTurnJournal('stored-1')).not.toThrow()
getItem.mockRestore()
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
expect(() => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
}).not.toThrow()
setItem.mockRestore()
const removeItem = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
throw new Error('remove denied')
})
expect(() => clearInFlightTurnJournal('stored-1')).not.toThrow()
})
it('discards malformed optional message metadata instead of throwing during recovery', () => {
window.localStorage.setItem(
sessionStorageKey('stored-1'),
JSON.stringify({
messages: [
{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'prompt' }], attachmentRefs: '@file:bad' },
{ id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'partial' }], pending: true }
],
streamId: 'a1',
turnStartedAt: 1,
updatedAt: Date.now()
})
)
const base = [user('db-u1', 'prompt')]
expect(() => recoverInFlightTurnJournal('stored-1', base)).not.toThrow()
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
})
describe('legacy journal migration', () => {
it('migrates the bounded v1 aggregate once and recovers its sessions', () => {
const first = {
messages: [user('u1', 'one'), assistant('a1', 'partial one', { pending: true })],
streamId: 'a1',
turnStartedAt: 1,
updatedAt: Date.now()
}
const second = {
messages: [
user('u2', 'two'),
{
id: 'a2',
role: 'assistant' as const,
parts: [
{
type: 'tool-call' as const,
toolCallId: 'tc-legacy',
toolName: 'terminal',
args: { command: 'large-output' },
result: 'x'.repeat(1024 * 1024)
},
{ type: 'text' as const, text: 'partial two' }
],
pending: true
}
],
streamId: 'a2',
turnStartedAt: 2,
updatedAt: Date.now()
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { one: first, two: second }, version: 1 }))
expect(readInFlightTurnJournal('one')).toEqual(first)
const migratedSecondRaw = window.localStorage.getItem(sessionStorageKey('two'))!
const migratedSecond = JSON.parse(migratedSecondRaw)
expect(migratedSecondRaw.length).toBeLessThan(256 * 1024)
expect(migratedSecond.messages[1].parts).toEqual([
{ args: {}, result: {}, toolCallId: 'tc-legacy', toolName: 'terminal', type: 'tool-call' },
{ text: 'partial two', type: 'text' }
])
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
expect(window.localStorage.getItem(MIGRATION_KEY)).toBe('1')
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { three: first }, version: 1 }))
expect(readInFlightTurnJournal('three')).toBeNull()
expect(window.localStorage.getItem(STORAGE_KEY)).not.toBeNull()
})
it('drops an oversized legacy aggregate without parsing it', () => {
window.localStorage.setItem(STORAGE_KEY, 'x'.repeat(2 * 1024 * 1024 + 1))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
expect(window.localStorage.getItem(MIGRATION_KEY)).toBe('1')
})
it('does not overwrite a newer per-session snapshot while migrating another legacy session', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
const legacyCurrent = {
messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
streamId: 'legacy-a1',
turnStartedAt: 1,
updatedAt: Date.now() - 1_000
}
const legacyOther = {
messages: [user('u2', 'other prompt'), assistant('a2', 'other partial', { pending: true })],
streamId: 'a2',
turnStartedAt: 2,
updatedAt: Date.now()
}
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ entries: { 'stored-1': legacyCurrent, other: legacyOther }, version: 1 })
)
expect(readInFlightTurnJournal('other')).toEqual(legacyOther)
expect(readInFlightTurnJournal('stored-1')?.messages[0]).toEqual(user('u1', 'do the thing'))
})
it('does not resurrect a legacy entry after that session settles before migration', () => {
const legacyCurrent = {
messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
streamId: 'legacy-a1',
turnStartedAt: 1,
updatedAt: Date.now()
}
const legacyOther = {
messages: [user('u2', 'other prompt'), assistant('a2', 'other partial', { pending: true })],
streamId: 'a2',
turnStartedAt: 2,
updatedAt: Date.now()
}
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ entries: { 'stored-1': legacyCurrent, other: legacyOther }, version: 1 })
)
persistInFlightTurnState(journalState({ busy: false, awaitingResponse: false, streamId: null }))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
expect(readInFlightTurnJournal('other')).toEqual(legacyOther)
})
})
describe('recoverInFlightTurnJournal', () => {
function journalEntry(messages: ChatMessage[]) {
persistInFlightTurnState(journalState({ messages, streamId: messages.at(-1)?.id ?? null }))
vi.advanceTimersByTime(400)
}
it('is a reference-preserving no-op when nothing is journaled', () => {
const base = [user('u1', 'do the thing')]
const result = recoverInFlightTurnJournal('stored-1', base)
expect(result.applied).toBe(false)
expect(result.messages).toBe(base)
})
it('appends the full tail when the base transcript never saw the turn', () => {
journalEntry([
user('u1', 'do the thing'),
assistantWithTool('assistant-stream-1', 'working on it', { pending: true })
])
const base = [user('u0', 'earlier turn'), assistant('a0', 'earlier reply')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
expect(result.applied).toBe(true)
expect(result.messages.map(m => m.id)).toEqual(['u0', 'a0', 'u1', 'assistant-stream-1'])
expect(result.streamId).toBe('assistant-stream-1')
})
it('appends only the assistant tail when the user row was persisted', () => {
journalEntry([
user('u1', 'do the thing'),
assistantWithTool('assistant-stream-1', 'working on it', { pending: true })
])
const base = [user('db-u1', 'do the thing')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
expect(result.applied).toBe(true)
expect(result.messages.map(m => m.id)).toEqual(['db-u1', 'assistant-stream-1'])
// Idle resume: the assistant-tail append path must not resurrect the
// stream target either, or the journal entry re-folds on every open.
expect(result.streamId).toBeNull()
const tail = result.messages.at(-1)!
expect(tail.pending).toBe(false)
expect(tail.parts[0]).toMatchObject({ type: 'tool-call' })
})
it('detects a committed reply as caught up and clears the entry', () => {
journalEntry([user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial', { pending: true })])
const base = [user('db-u1', 'do the thing'), assistant('db-a1', 'full committed reply')]
const result = recoverInFlightTurnJournal('stored-1', base)
expect(result.applied).toBe(false)
expect(result.caughtUp).toBe(true)
expect(result.messages).toBe(base)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('overlays the backend text-only projection instead of dropping local tool progress', () => {
// Sweeper regression on #44339: a backend `inflight` assistant snapshot
// (text only) used to mark the richer local tail "caught up" and delete
// locally recorded tool calls. After #76444, longer text wins only when it
// is a strict extension of the journal answer (flat thinking dumps must
// not replace structured answer text).
journalEntry([
user('u1', 'do the thing'),
assistantWithTool('assistant-stream-old', 'local part', { pending: true })
])
const base = [
user('db-u1', 'do the thing'),
assistant('assistant-stream-rt9', 'local part and more from the backend snapshot', { pending: true })
]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
expect(result.applied).toBe(true)
expect(result.caughtUp).toBe(false)
expect(result.messages).toHaveLength(2)
const merged = result.messages.at(-1)!
// Keeps the BASE projection row id so live deltas keep landing on it.
expect(merged.id).toBe('assistant-stream-rt9')
expect(result.streamId).toBe('assistant-stream-rt9')
// Journal structure survives; strict-extension backend text wins.
expect(merged.parts[0]).toMatchObject({ type: 'tool-call', toolName: 'terminal' })
expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'local part and more from the backend snapshot' })
// Still in flight — the journal must NOT be cleared.
expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
})
it('keeps journal answer text when a longer flat dump is not a strict extension (#76444)', () => {
journalEntry([user('u1', 'do the thing'), assistantWithTool('assistant-stream-old', 'partial', { pending: true })])
const base = [
user('db-u1', 'do the thing'),
assistant(
'assistant-stream-rt9',
'thinking chatter\nRan terminal\npartial and unrelated dump longer than answer',
{ pending: true }
)
]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
const merged = result.messages.at(-1)!
expect(merged.parts[0]).toMatchObject({ type: 'tool-call', toolName: 'terminal' })
expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'partial' })
})
it('keeps the journal text when it is longer than the projection text', () => {
journalEntry([
user('u1', 'do the thing'),
assistantWithTool('assistant-stream-old', 'a much longer locally journaled partial answer', { pending: true })
])
const base = [user('db-u1', 'do the thing'), assistant('assistant-stream-rt9', 'thin', { pending: true })]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
const merged = result.messages.at(-1)!
expect(merged.id).toBe('assistant-stream-rt9')
expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'a much longer locally journaled partial answer' })
})
// ── Scrambled-transcript regression (duplicate trailing answers) ───────────
// The journal can outlive the turn it recorded (reclaim/reconnect/restart
// races skip the settle that would clear it). On resume the fold then
// re-appends content that the committed transcript ALREADY holds, rendering
// the same answers twice at the end of the conversation. Reported on the
// desktop as "the answer was already there, but it was inputted again".
it('does not re-append committed answers when the journaled user row never persisted', () => {
// A resume projection can journal a `user-inflight-*` row that was never
// written to the DB (and may even belong to a different conversation).
// Because no base user matches it, the fold used to treat the whole tail
// as unknown and append it — duplicating the assistant answers below.
journalEntry([
user('user-inflight-a3c2beb1', 'a stray user bubble that never persisted'),
assistant('assistant-stream-1', 'the committed answer')
])
const base = [user('db-u1', 'the real prompt'), assistant('db-a1', 'the committed answer')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
expect(result.caughtUp).toBe(true)
expect(result.applied).toBe(false)
expect(result.messages).toBe(base)
expect(result.messages.map(m => m.id)).toEqual(['db-u1', 'db-a1'])
// The stale entry is cleared so the next resume stays clean.
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('does not re-append committed answers when the journal tail has no user row', () => {
// A tail captured after a partial hydrate can end on assistant rows with
// no user prompt before them. The old code appended them verbatim, so the
// transcript ended with a duplicate of an answer that was already settled.
journalEntry([assistant('assistant-stream-1', 'the committed answer')])
const base = [user('db-u1', 'the real prompt'), assistant('db-a1', 'the committed answer')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
expect(result.caughtUp).toBe(true)
expect(result.messages).toBe(base)
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
it('keeps appending a genuinely unknown turn (crash recovery still works)', () => {
// The staleness check must not swallow a tail the base never saw: that is
// the crash-recovery path the journal exists for.
journalEntry([user('u1', 'the live prompt'), assistant('assistant-stream-1', 'partial answer', { pending: true })])
const base = [user('db-u0', 'an earlier turn'), assistant('db-a0', 'earlier reply')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
expect(result.applied).toBe(true)
expect(result.caughtUp).toBe(false)
expect(result.messages.map(m => m.id)).toEqual(['db-u0', 'db-a0', 'u1', 'assistant-stream-1'])
expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
})
it('does not resurrect the journal streamId on a not-running resume (journal self-clear)', () => {
// The fold used to carry the stale entry's streamId onto the resumed state
// even when the backend reported the session idle. persistInFlightTurnState
// then re-wrote the journal instead of clearing it, so the same stale tail
// was folded again on every open — the scramble never healed.
journalEntry([user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial answer', { pending: true })])
const base = [user('db-u0', 'an earlier turn'), assistant('db-a0', 'earlier reply')]
const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
expect(result.applied).toBe(true)
expect(result.streamId).toBeNull()
})
})
describe('mergeInFlightMessages', () => {
it('treats an error-bearing assistant row as recoverable content', () => {
const tail = [user('u1', 'do the thing'), assistant('a-err', '', { error: 'provider exploded' })]
const result = mergeInFlightMessages([user('db-u1', 'do the thing')], tail)
expect(result.applied).toBe(true)
expect(result.messages.at(-1)?.error).toBe('provider exploded')
})
it('ignores hidden rows when extracting nothing to recover', () => {
const result = mergeInFlightMessages([], [user('u1', 'x')])
expect(result.applied).toBe(false)
expect(result.caughtUp).toBe(false)
})
})
describe('mid-turn redirect corrections', () => {
beforeEach(() => {
window.localStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// A redirect inserts its correction as a second user row directly before the
// live reply, so the turn opens with a RUN of user rows. Journaling only back
// to the nearest one lost the prompt that actually started the turn — the
// vanishing user bubble.
it('journals the whole user run, not just the correction', () => {
persistInFlightTurnState({
awaitingResponse: false,
busy: true,
messages: [
user('user-1', 'remove the session counts'),
user('user-2', 'hurry up'),
assistant('assistant-stream-1', 'Moving.', { pending: true })
],
storedSessionId: 'stored-redirect',
streamId: 'assistant-stream-1',
turnStartedAt: Date.now()
})
vi.advanceTimersByTime(400)
const journaled = readInFlightTurnJournal('stored-redirect')?.messages ?? []
expect(journaled.map(message => message.parts.map(part => (part as { text: string }).text).join(''))).toEqual([
'remove the session counts',
'hurry up',
'Moving.'
])
})
it('still stops at an assistant boundary so prior turns are not journaled', () => {
persistInFlightTurnState({
awaitingResponse: false,
busy: true,
messages: [
user('user-old', 'an earlier turn'),
assistant('assistant-old', 'an earlier answer'),
user('user-1', 'the live prompt'),
assistant('assistant-stream-1', 'Moving.', { pending: true })
],
storedSessionId: 'stored-boundary',
streamId: 'assistant-stream-1',
turnStartedAt: Date.now()
})
vi.advanceTimersByTime(400)
const journaled = readInFlightTurnJournal('stored-boundary')?.messages ?? []
expect(journaled.map(message => message.id)).toEqual(['user-1', 'assistant-stream-1'])
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { lastInputModality } from './input-modality'
describe('lastInputModality', () => {
it('defaults to keyboard before any input', () => {
expect(lastInputModality()).toBe('keyboard')
})
it('tracks the device behind the last interaction', () => {
document.dispatchEvent(new Event('pointerdown'))
expect(lastInputModality()).toBe('pointer')
document.dispatchEvent(new Event('keydown'))
expect(lastInputModality()).toBe('keyboard')
})
it('sees events a handler stops from bubbling (capture phase)', () => {
const target = document.createElement('button')
document.body.append(target)
target.addEventListener('pointerdown', event => event.stopPropagation())
target.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(lastInputModality()).toBe('pointer')
target.remove()
})
})
+26
View File
@@ -0,0 +1,26 @@
/** Which input device drove the most recent interaction.
*
* Chromium's `:focus-visible` is not enough to tell a mouse pick from a Tab.
* Radix menus autofocus their content on open and keyboard-navigate their
* items, so by the time a mouse click closes the menu and restores focus to
* the trigger, Chromium has decided the page is in keyboard modality and
* matches `:focus-visible` on that restore (verified in Chrome — the model
* pill's tooltip popped open after every mouse pick). Track the device
* ourselves and use it to qualify `:focus-visible`.
*/
export type InputModality = 'keyboard' | 'pointer'
let modality: InputModality = 'keyboard'
/** Capture-phase so a `stopPropagation` deeper in the tree can't blind us. */
if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', () => (modality = 'pointer'), { capture: true, passive: true })
document.addEventListener('keydown', () => (modality = 'keyboard'), { capture: true, passive: true })
}
/** The device behind the last pointerdown/keydown. Defaults to `keyboard` so a
* surface that has seen no input yet keeps the accessible behavior. */
export function lastInputModality(): InputModality {
return modality
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { tryFormatJson } from './json-format'
describe('tryFormatJson', () => {
it('pretty-prints compact JSON', () => {
expect(tryFormatJson('{"a":1,"b":[2,3]}')).toEqual({
ok: true,
text: '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}'
})
})
it('leaves empty input unchanged', () => {
expect(tryFormatJson(' ')).toEqual({ ok: true, text: ' ' })
})
it('reports parse errors', () => {
const result = tryFormatJson('{bad')
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error.length).toBeGreaterThan(0)
}
})
})
+15
View File
@@ -0,0 +1,15 @@
export type FormatJsonResult = { ok: true; text: string } | { ok: false; error: string }
export function tryFormatJson(raw: string): FormatJsonResult {
const text = raw.trim()
if (!text) {
return { ok: true, text: raw }
}
try {
return { ok: true, text: JSON.stringify(JSON.parse(text) as unknown, null, 2) }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
@@ -0,0 +1,131 @@
import { JsonRpcGatewayClient } from '@hermes/shared'
import { afterEach, describe, expect, it, vi } from 'vitest'
interface ListenerEntry {
callback: (event: any) => void
once: boolean
}
class FakeSocket {
static readonly CLOSED = 3
static readonly OPEN = 1
readonly sent: string[] = []
readyState = FakeSocket.OPEN
private listeners = new Map<string, ListenerEntry[]>()
addEventListener(type: string, callback: (event: any) => void, options?: AddEventListenerOptions): void {
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once: Boolean(options?.once) })
this.listeners.set(type, entries)
}
close(): void {
if (this.readyState === FakeSocket.CLOSED) {
return
}
this.readyState = FakeSocket.CLOSED
this.emit('close', { code: 1000 })
}
emit(type: string, event: any = {}): void {
const entries = [...(this.listeners.get(type) ?? [])]
for (const entry of entries) {
entry.callback(event)
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
}
}
message(frame: unknown): void {
this.emit('message', { data: JSON.stringify(frame) })
}
removeEventListener(type: string, callback: (event: any) => void): void {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(entry => entry.callback !== callback)
)
}
send(payload: string): void {
this.sent.push(payload)
}
}
describe('JsonRpcGatewayClient heartbeat recovery', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('invalidates a silently dead advertised socket and ignores its late frames', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: FakeSocket.OPEN })
const socket = new FakeSocket()
const states: string[] = []
const events: string[] = []
const client = new JsonRpcGatewayClient({
heartbeatDeadlineMs: 45,
heartbeatIntervalMs: 15,
socketFactory: () => socket as unknown as WebSocket
})
client.onState(state => states.push(state))
client.onAny(event => events.push(event.type))
const connected = client.connect('ws://gateway.test/api/ws')
socket.emit('open')
await connected
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { heartbeat: true } }
})
await vi.advanceTimersByTimeAsync(61)
expect(client.connectionState).toBe('closed')
expect(socket.readyState).toBe(FakeSocket.CLOSED)
expect(states.at(-1)).toBe('closed')
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'message.complete', payload: { text: 'late duplicate' } }
})
expect(events).toEqual(['gateway.ready'])
})
it('keeps older backends open when gateway.ready does not advertise heartbeat', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: FakeSocket.OPEN })
const socket = new FakeSocket()
const client = new JsonRpcGatewayClient({
heartbeatDeadlineMs: 45,
heartbeatIntervalMs: 15,
socketFactory: () => socket as unknown as WebSocket
})
const connected = client.connect('ws://gateway.test/api/ws')
socket.emit('open')
await connected
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: {} }
})
await vi.advanceTimersByTimeAsync(1_000)
expect(client.connectionState).toBe('open')
expect(socket.readyState).toBe(FakeSocket.OPEN)
expect(socket.sent).toEqual([])
})
})
@@ -0,0 +1,236 @@
import { JsonRpcGatewayClient } from '@hermes/shared'
import { afterEach, describe, expect, it, vi } from 'vitest'
interface ListenerEntry {
callback: (event: any) => void
once: boolean
}
class LoopbackSocket {
static readonly CLOSED = 3
static readonly CONNECTING = 0
static readonly OPEN = 1
readyState = LoopbackSocket.CONNECTING
private listeners = new Map<string, ListenerEntry[]>()
constructor(
readonly generation: number,
private readonly backend: RecoveryBackend
) {}
addEventListener(type: string, callback: (event: any) => void, options?: AddEventListenerOptions): void {
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once: Boolean(options?.once) })
this.listeners.set(type, entries)
}
close(): void {
if (this.readyState === LoopbackSocket.CLOSED) {
return
}
this.readyState = LoopbackSocket.CLOSED
this.emit('close', { code: 1000 })
}
message(frame: unknown): void {
this.emit('message', { data: JSON.stringify(frame) })
}
open(): void {
this.readyState = LoopbackSocket.OPEN
this.emit('open', {})
this.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { heartbeat: true } }
})
}
removeEventListener(type: string, callback: (event: any) => void): void {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(entry => entry.callback !== callback)
)
}
send(payload: string): void {
this.backend.receive(this, JSON.parse(payload) as Record<string, any>)
}
private emit(type: string, event: any): void {
const entries = [...(this.listeners.get(type) ?? [])]
for (const entry of entries) {
entry.callback(event)
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
}
}
}
class RecoveryBackend {
readonly sockets: LoopbackSocket[] = []
readonly messages: Array<{ role: 'assistant' | 'user'; content: string }> = []
promptSubmits = 0
createSocket(): LoopbackSocket {
const socket = new LoopbackSocket(this.sockets.length + 1, this)
this.sockets.push(socket)
queueMicrotask(() => socket.open())
return socket
}
receive(socket: LoopbackSocket, frame: Record<string, any>): void {
if (socket.generation === 1 && frame.method === 'prompt.submit') {
this.promptSubmits += 1
this.messages.push(
{ role: 'user', content: String(frame.params?.text ?? '') },
{ role: 'assistant', content: 'persisted while transport was blackholed' }
)
// Accepted and persisted, but every outbound packet (including the RPC
// response, heartbeat acknowledgement, and message.complete) is dropped.
return
}
if (socket.generation === 1) {
return
}
if (frame.method === 'gateway.ping') {
socket.message({ jsonrpc: '2.0', id: frame.id, result: { ok: true } })
return
}
if (frame.method === 'session.activate') {
socket.message({
jsonrpc: '2.0',
id: frame.id,
result: {
session_id: 'runtime-1',
session_key: 'stored-1',
messages: this.messages,
running: false,
status: 'idle'
}
})
}
}
}
describe('hermes-ws-recovery-v1 silent blackhole', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('keeps a replacement socket open when a superseded connect times out', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: LoopbackSocket.OPEN })
const backend = new RecoveryBackend()
const sockets: LoopbackSocket[] = []
const states: string[] = []
const client = new JsonRpcGatewayClient({
connectTimeoutMs: 50,
heartbeatIntervalMs: 0,
socketFactory: () => {
const socket = new LoopbackSocket(sockets.length + 1, backend)
sockets.push(socket)
return socket as unknown as WebSocket
}
})
client.onState(state => states.push(state))
const staleConnect = client.connect('ws://gateway.test/a')
const staleRejection = expect(staleConnect).rejects.toThrow('WebSocket connection failed')
client.close()
const currentConnect = client.connect('ws://gateway.test/b')
sockets[1].open()
await currentConnect
expect(client.connectionState).toBe('open')
await vi.advanceTimersByTimeAsync(50)
await staleRejection
expect(client.connectionState).toBe('open')
expect(sockets[1].readyState).toBe(LoopbackSocket.OPEN)
expect(states.slice(states.lastIndexOf('open'))).toEqual(['open'])
client.close()
})
it('recovers the persisted final on a replacement socket without duplicating the prompt', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: LoopbackSocket.OPEN })
const backend = new RecoveryBackend()
const client = new JsonRpcGatewayClient({
heartbeatDeadlineMs: 45,
heartbeatIntervalMs: 15,
socketFactory: () => backend.createSocket() as unknown as WebSocket
})
let epoch = 0
let intentionalShutdown = false
let recovered: Promise<any> | null = null
client.onState(state => {
if (state === 'open') {
epoch += 1
if (epoch > 1) {
recovered = client.request('session.activate', { session_id: 'runtime-1' })
}
}
if (state === 'closed' && !intentionalShutdown) {
void client.connect('ws://gateway.test/api/ws')
}
})
await client.connect('ws://gateway.test/api/ws')
await Promise.resolve()
const ambiguousSubmit = client.request('prompt.submit', { session_id: 'runtime-1', text: 'one prompt' }).then(
() => null,
error => error as Error
)
await vi.advanceTimersByTimeAsync(61)
await vi.waitFor(() => expect(backend.sockets).toHaveLength(2))
await vi.waitFor(() => expect(recovered).not.toBeNull())
await expect(ambiguousSubmit).resolves.toMatchObject({
message: 'WebSocket heartbeat acknowledgement timed out'
})
await expect(recovered).resolves.toMatchObject({
messages: [
{ role: 'user', content: 'one prompt' },
{ role: 'assistant', content: 'persisted while transport was blackholed' }
],
running: false,
status: 'idle'
})
expect(backend.promptSubmits).toBe(1)
intentionalShutdown = true
client.close()
await vi.advanceTimersByTimeAsync(1_000)
expect(backend.sockets).toHaveLength(2)
})
})
@@ -0,0 +1,129 @@
// connect() must reject before WebSocket coerces garbage into
// `ws://<origin>/[object%20Object]` (#68250 stale-emit boot loop).
import { JsonRpcGatewayClient, JsonRpcGatewayError } from '@hermes/shared'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
class FakeSocket {
static OPEN = 1
readyState = 0
addEventListener = vi.fn((type: string, handler: () => void) => {
if (type === 'open') {
setTimeout(() => {
this.readyState = FakeSocket.OPEN
handler()
}, 0)
}
})
removeEventListener = vi.fn()
close = vi.fn()
send = vi.fn()
}
describe('JsonRpcGatewayClient connect() URL guard', () => {
beforeEach(() => {
vi.stubGlobal('WebSocket', FakeSocket) // jsdom has none; class reads WebSocket.OPEN
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('rejects a non-string IPC result object', async () => {
const client = new JsonRpcGatewayClient()
await expect(client.connect({ ok: true, wsUrl: 'ws://127.0.0.1:1/api/ws' } as unknown as string)).rejects.toThrow(
/requires a ws:\/\/ or wss:\/\/ URL string, got type "object"/
)
})
it('rejects a non-ws URL string', async () => {
const client = new JsonRpcGatewayClient()
await expect(client.connect('http://127.0.0.1:1234/api/ws')).rejects.toThrow(
/requires a ws:\/\/ or wss:\/\/ URL string/
)
})
it('rejects a malformed ws URL before opening a socket', async () => {
const client = new JsonRpcGatewayClient()
await expect(client.connect('ws://')).rejects.toThrow(/requires a ws:\/\/ or wss:\/\/ URL string/)
expect(client.connectionState).toBe('idle')
})
it('keeps connection state idle on rejection', async () => {
const client = new JsonRpcGatewayClient()
await client.connect(undefined as unknown as string).catch(() => undefined)
expect(client.connectionState).toBe('idle')
})
it('accepts ws:// and wss://', async () => {
for (const url of ['ws://127.0.0.1:1234/api/ws?token=t', 'wss://gw.example.com/api/ws?ticket=t']) {
const client = new JsonRpcGatewayClient({ socketFactory: () => new FakeSocket() as unknown as WebSocket })
await client.connect(url)
expect(client.connectionState).toBe('open')
}
})
})
describe('JsonRpcGatewayClient structured errors', () => {
it('rejects with JsonRpcGatewayError including code and data', async () => {
class RespondingSocket {
static OPEN = 1
readyState = 0
private messageHandler: ((event: { data: string }) => void) | null = null
addEventListener = vi.fn((type: string, handler: (event?: { data: string }) => void) => {
if (type === 'open') {
setTimeout(() => {
this.readyState = RespondingSocket.OPEN
handler()
}, 0)
}
if (type === 'message') {
this.messageHandler = handler as (event: { data: string }) => void
}
})
removeEventListener = vi.fn()
close = vi.fn()
send = vi.fn((raw: string) => {
const req = JSON.parse(raw) as { id: string }
queueMicrotask(() => {
this.messageHandler?.({
data: JSON.stringify({
jsonrpc: '2.0',
id: req.id,
error: {
code: 4018,
message: 'target user message is no longer in session history',
data: { user_turn_count: 2, ordinal: 5, segment_ordinal: 3 }
}
})
})
})
})
}
vi.stubGlobal('WebSocket', RespondingSocket)
try {
const client = new JsonRpcGatewayClient({
requestTimeoutMs: 5_000,
socketFactory: () => new RespondingSocket() as unknown as WebSocket
})
await client.connect('ws://127.0.0.1:1234/api/ws?token=t')
await expect(client.request('prompt.submit', { session_id: 's' })).rejects.toEqual(
expect.objectContaining({
name: 'JsonRpcGatewayError',
code: 4018,
message: 'target user message is no longer in session history',
data: { user_turn_count: 2, ordinal: 5, segment_ordinal: 3 }
})
)
expect(new JsonRpcGatewayError('x')).toBeInstanceOf(Error)
} finally {
vi.unstubAllGlobals()
}
})
})
+229
View File
@@ -0,0 +1,229 @@
/**
* Memoizing wrapper around `rehype-katex`.
*
* Why: the default `@streamdown/math` plugin runs `rehype-katex` on every
* markdown commit. During streaming, that means each new token re-runs
* KaTeX on EVERY math node in the message — including equations that
* haven't changed since the last token. For math-heavy responses (a
* model deriving an equation step-by-step) this becomes a major source
* of jank: 20 unchanged equations each pay ~5–20ms of katex.renderToString
* work per token, adding up to hundreds of ms of CPU bound work that
* delays the next streaming update.
*
* What this plugin does: walk the hast tree looking for the math nodes
* that `remark-math` emits (`<code class="math-inline">…</code>` for
* inline and `<pre><code class="math-display">…</code></pre>` for
* display), key them by `(displayMode, value)`, and serve them from an
* in-memory LRU cache when we've rendered the same equation before.
* Cache misses still go through `katex.renderToString`; cache hits
* return the previously generated hast subtree.
*
* Result: each unique equation only pays the katex cost once. Adding
* one new equation to a paragraph re-renders just that one equation
* instead of all of them. The cache is process-global so it survives
* moves between messages (e.g., re-rendering a session).
*
* Compatibility: the produced hast structure matches what `rehype-katex`
* itself produces — we use the same `hast-util-from-html-isomorphic`
* fragment parsing and the same parent-splice semantics, including the
* `<pre>`-walk-up for display mode. Drop-in replacement for the math
* slot in streamdown's PluginConfig.
*
* Wire it in via `createMemoizedMathPlugin`:
*
* import { createMemoizedMathPlugin } from '@/lib/katex-memo'
* const math = createMemoizedMathPlugin({ singleDollarTextMath: true })
* <Streamdown plugins={{ math }} ... />
*/
import type { Element, ElementContent, Parent, Root } from 'hast'
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
import { toText } from 'hast-util-to-text'
import katex from 'katex'
import remarkMath from 'remark-math'
import type { Pluggable } from 'unified'
import { SKIP, visitParents } from 'unist-util-visit-parents'
import type { VFile } from 'vfile'
import { LruCache } from '@/lib/lru-cache'
interface KatexMemoOptions {
/**
* Color used for KaTeX errors when we fall back to the lenient parser.
* Mirrors `@streamdown/math`'s default so the visual output is identical.
*/
errorColor?: string
}
interface MathPluginConfig {
/**
* Match `singleDollarTextMath` from `@streamdown/math`. When true the
* remark-math parser treats `$x$` as inline math; when false it requires
* `$$x$$`. Models almost always emit the single-dollar form, so we
* default it to true at the createMemoizedMathPlugin call site.
*/
singleDollarTextMath?: boolean
errorColor?: string
}
/** Cached rendered hast — children to splice into the math node's parent. */
type CachedRender = ElementContent[]
const CACHE_LIMIT = 512
const cache = new LruCache<string, CachedRender>(CACHE_LIMIT)
function cacheKey(displayMode: boolean, value: string): string {
// `\u0001` is a control character that (a) won't appear in normal
// markdown and (b) is a single byte so the join is cheap.
return `${displayMode ? 'd' : 'i'}\u0001${value}`
}
/**
* Render one math expression with the same two-pass strategy `rehype-katex`
* uses internally: try strict first (so genuine TeX errors get reported in
* the VFile message stream), and on failure fall back to lenient mode so
* the document still renders without a thrown exception. The lenient
* fallback paints the equation in `errorColor` instead of erroring out.
*/
function renderMath(
value: string,
displayMode: boolean,
errorColor: string,
file: VFile,
element: Element
): ElementContent[] {
let html: string
try {
html = katex.renderToString(value, { displayMode, throwOnError: true })
} catch (error) {
const cause = error as Error
file.message('Could not render math with KaTeX', {
cause,
place: element.position,
ruleId: cause.name?.toLowerCase() ?? 'katex',
source: 'rehype-katex-memo'
})
try {
html = katex.renderToString(value, {
displayMode,
errorColor,
strict: 'ignore',
throwOnError: false
})
} catch {
// Last-resort fallback — render the source text inside a styled span
// so the user at least sees what was supposed to be there. Mirrors
// rehype-katex's own escape hatch.
return [
{
type: 'element',
tagName: 'span',
properties: {
className: ['katex-error'],
style: `color:${errorColor}`,
title: String(error)
},
children: [{ type: 'text', value }]
}
]
}
}
const fragment = fromHtmlIsomorphic(html, { fragment: true })
return fragment.children as ElementContent[]
}
/**
* The actual rehype plugin. Wraps `rehype-katex`'s logic with our LRU
* cache. Mirrors the upstream visitor exactly except for the cache lookup
* and an LRU.set on miss.
*/
function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
const errorColor = options.errorColor ?? 'var(--color-muted-foreground)'
return () =>
function transform(tree: Root, file: VFile): undefined {
visitParents(tree, 'element', (element, parents) => {
const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : []
// Match the same class set rehype-katex looks for. `language-math`
// is the markdown ` ```math ` form, `math-inline` is what
// remark-math emits for `$x$`, `math-display` for `$$x$$`.
const languageMath = classes.includes('language-math')
const mathDisplay = classes.includes('math-display')
const mathInline = classes.includes('math-inline')
if (!(languageMath || mathDisplay || mathInline)) {
return
}
let displayMode = mathDisplay
let scope: Element = element
let parent: Parent | undefined = parents[parents.length - 1]
// For ` ```math ` the scope walks up to the wrapping <pre> and
// we treat it as display math. Same logic rehype-katex uses.
if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') {
scope = parent as Element
parent = parents[parents.length - 2]
displayMode = true
}
// No parent means the math node is at the root — there's nothing
// to splice into, so bail. This shouldn't happen for properly
// nested markdown but is the same defensive guard rehype-katex has.
if (!parent) {
return
}
const value = toText(scope, { whitespace: 'pre' })
const key = cacheKey(displayMode, value)
let cached = cache.get(key)
if (!cached) {
cached = renderMath(value, displayMode, errorColor, file, scope)
cache.set(key, cached)
}
// Splice CLONES of the cached children into the parent. Reusing
// the same node instances across renders would let downstream
// rehype plugins or toJsxRuntime mutate the cached subtree —
// breaking the next cache hit. structuredClone is ~100µs per
// equation, well below the ~5–20ms katex.renderToString cost
// we're avoiding.
const clonedChildren = cached.map(child => structuredClone(child))
const index = parent.children.indexOf(scope as ElementContent)
if (index === -1) {
return
}
parent.children.splice(index, 1, ...clonedChildren)
return SKIP
})
}
}
/**
* Build a streamdown MathPlugin object that uses the memoized rehype-katex
* wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`.
*/
export function createMemoizedMathPlugin(config: MathPluginConfig = {}) {
const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }]
const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor })
return {
name: 'katex' as const,
type: 'math' as const,
remarkPlugin,
rehypePlugin,
getStyles: () => 'katex/dist/katex.min.css'
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { en } from '@/i18n/en'
import { defaultBindings, KEYBIND_ACTIONS, keybindAction } from './actions'
describe('session.archive keybind action', () => {
it('is registered under the session category', () => {
const action = keybindAction('session.archive')
expect(action).toBeDefined()
expect(action?.category).toBe('session')
})
it('ships unbound so it does not claim a chord for every user', () => {
const action = keybindAction('session.archive')
expect(action?.defaults).toEqual([])
// A missing entry would silently drop from the panel; an accidental
// default binding would change behaviour for everyone. Guard both.
expect(defaultBindings()['session.archive']).toEqual([])
})
it('has an English label so it renders in the shortcuts panel', () => {
expect(en.keybinds.actions['session.archive']).toBe('Archive current session')
})
it('appears exactly once in KEYBIND_ACTIONS', () => {
const matches = KEYBIND_ACTIONS.filter(action => action.id === 'session.archive')
expect(matches).toHaveLength(1)
})
})
+278
View File
@@ -0,0 +1,278 @@
// The single source of truth for rebindable desktop hotkeys.
//
// Each entry is pure metadata: an id, a category, and the default combo(s).
// Handlers are wired separately in `use-keybinds.ts` (they need React context
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import { registry } from '@/contrib/registry'
import { IS_MAC } from './combo'
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
// the panel subtitle (not as its own row).
export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
export interface KeybindActionMeta {
id: string
category: KeybindCategory
/** Default combos. Empty = shipped unbound (user can assign one). */
defaults: readonly string[]
/** Display label for CONTRIBUTED actions (built-ins use i18n). */
label?: string
}
// Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then
// ⌘⌥1…⌘⌥9 for 10-18. The default profile gets the two-key mnemonic ⌘D (see
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
export const SESSION_SLOT_COUNT = 9
const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_SLOT_COUNT }, (_, i) => ({
id: `session.slot.${i + 1}`,
category: 'session' as const,
defaults: [`ctrl+${i + 1}`]
}))
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
// Soft `/` / Enter focus (gated); other printables type-to-focus unbound.
{ id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] },
// ⌘⇧M — "m" for model; the convention chat apps converged on (LibreChat,
// Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's
// live dropdown on the pane under the pointer, else the active composer.
{ id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] },
// Voice conversation toggle. Matches the documented `voice.record_key`
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
// toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar
// chord, so ship it unbound there (rebindable in the panel) rather than
// stealing the long-standing sidebar binding.
{ id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] },
// ── Profiles ─────────────────────────────────────────────────────────────
{ id: 'profile.default', category: 'profiles', defaults: ['mod+d'] },
...PROFILE_SWITCH_ACTIONS,
{ id: 'profile.next', category: 'profiles', defaults: ['mod+shift+]'] },
{ id: 'profile.prev', category: 'profiles', defaults: ['mod+shift+['] },
{ id: 'profile.toggleAll', category: 'profiles', defaults: ['mod+shift+0'] },
{ id: 'profile.create', category: 'profiles', defaults: [] },
// ── Session ──────────────────────────────────────────────────────────────
// `shift+n` was dropped from the defaults (#76185): a bare shifted letter
// hijacked normal typing — pressing uppercase N outside an input (or via an
// IME) created a new session unexpectedly. The deliberate ⌘/Ctrl+N chord
// stays; users who liked ⇧N can rebind it in the panel.
{ id: 'session.new', category: 'session', defaults: ['mod+n'] },
{ id: 'session.newTab', category: 'session', defaults: ['mod+t'] },
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab', 'ctrl+pagedown'] },
{ id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab', 'ctrl+pageup'] },
...SESSION_SLOT_ACTIONS,
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
// Archive the active session. Ships unbound (like `session.togglePin`) so an
// irreversible-feeling, mouse-only action doesn't silently claim a chord for
// every user — surfaced in the panel for opt-in binding (the issue suggests
// ⌘⇧⌫ / Ctrl+Shift+⌫).
{ id: 'session.archive', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
// ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
// workspace::Open). Picks a folder and opens it as a project (upsert:
// enters the owning project when one exists, else creates one), landing on
// a fresh session anchored there.
{ id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
{ id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] },
{ id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] },
{ id: 'nav.profiles', category: 'navigation', defaults: [] },
{ id: 'nav.skills', category: 'navigation', defaults: [] },
{ id: 'nav.messaging', category: 'navigation', defaults: [] },
{ id: 'nav.artifacts', category: 'navigation', defaults: [] },
{ id: 'nav.cron', category: 'navigation', defaults: [] },
{ id: 'nav.agents', category: 'navigation', defaults: [] },
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
// ⌘⇧S — "s" for status bar. VS Code ships
// `workbench.action.toggleStatusbarVisibility` unbound (it's a chord-free
// gap in their View family) and Hermes has no chord dispatcher, so this
// takes the nearest free single combo instead of a ⌘K ⌘S two-stroke.
{ id: 'view.toggleStatusbar', category: 'view', defaults: ['mod+shift+s'] },
// ⌥⌘T — "t" for tabs, reaching past ⇧ because ⌘⇧T is reopen-closed-tab
// everywhere. Ships BOUND, unlike VS Code's settings-only tab-bar switch:
// here the hide can take away every other affordance the zone had, so the
// way back has to already exist. (⌥+letter emits a symbol on macOS; the
// binding resolves through KeyT via comboFromEvent's `event.code` fallback.)
{ id: 'view.toggleTabStrip', category: 'view', defaults: ['mod+alt+t'] },
// ⌘G — "g" for git; the review pane is the source-control view.
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
// ⌘⇧L — "L" for location, the address-bar chord every browser shares. Plain
// ⌘L is the terminal's selection shortcut, hence the shift.
{ id: 'view.showBrowser', category: 'view', defaults: ['mod+shift+l'] },
// ⌘⇧H — "h" for HUD. Enters/leaves the chrome-free floating chat: the app
// window steps aside and a composer + live reply float over whatever the
// user is working in. Ships bound because the whole point is leaving the app
// without reaching for it — but the titlebar button is the discoverable door.
{ id: 'view.toggleHud', category: 'view', defaults: ['mod+shift+h'] },
// Control+` everywhere (literal `ctrl`, NOT `mod`): ⌘` is macOS-reserved for
// cycling app windows, so VS Code/Cursor/Zed bind the terminal to Ctrl+` on
// every platform. Off macOS `ctrl` folds to `mod` (= Ctrl), so it's unchanged.
// Toggle reveals the terminal (opening one if none exist); Shift spawns a new one.
{ id: 'view.showTerminal', category: 'view', defaults: ['ctrl+`'] },
{ id: 'view.newTerminal', category: 'view', defaults: ['ctrl+shift+`'] },
// Same Ctrl(+Shift) terminal family: arrows walk the (vertical) tab rail, W
// kills the active one. ⌘W is taken (close preview tab) and ⌘⇧[ ] are profiles,
// so these stay on `ctrl` — distinct on macOS, folding to Ctrl elsewhere.
{ id: 'view.nextTerminal', category: 'view', defaults: ['ctrl+shift+down'] },
{ id: 'view.prevTerminal', category: 'view', defaults: ['ctrl+shift+up'] },
{ id: 'view.closeTerminal', category: 'view', defaults: ['ctrl+shift+w'] },
// ⌘\ — the backslash reads like a mirror line flipping the layout.
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
// ⌘W closes the focused zone's active tab — its own tab strip (preview) or
// the tree tab (session tiles, files, terminal). The uncloseable workspace
// is a no-op. ⌘⇧T reopens the last closed tab where it was.
{ id: 'view.closeTab', category: 'view', defaults: ['mod+w'] },
{ id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] },
// ⌘F — open the find-in-page bar. `actionAllowedInInput` lets this action
// fire from inside a textarea / contenteditable (matches browser behavior
// so typing in the composer and pressing ⌘F focuses find, not 'f').
{ id: 'view.findInPage', category: 'view', defaults: ['mod+f'] },
// ⌘G / ⌘⇧G step matches — the platform-standard find-next/find-previous
// pair (Chrome, Safari, VS Code, and Claude Desktop all ship it). No
// `defaults` here on purpose: ⌘G already belongs to `view.toggleReview`,
// and shipping a duplicate default would flag a permanent conflict in the
// keybinds panel. While the find bar is OPEN, its capture-phase listener
// claims ⌘G/⌘⇧G and stops propagation (see components/find-bar.tsx), so
// stepping works out of the box and the review toggle keeps the key the
// rest of the time. These entries exist so the panel documents the pair
// and a user who prefers a dedicated chord can bind one.
{ id: 'view.findNext', category: 'view', defaults: [] },
{ id: 'view.findPrevious', category: 'view', defaults: [] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
{ id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] }
]
export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id)
const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action]))
// ── Contributed actions — the `keybinds` registry area ──────────────────────
// Same declarative schema as every other surface: a data contribution carries
// the action's metadata AND its handler. Contributed actions are first-class:
// they dispatch, appear in the panel, are rebindable, and their overrides
// persist exactly like built-ins. Built-in ids can't be shadowed.
export const KEYBINDS_AREA = 'keybinds'
/** Payload of a `keybinds` data contribution. */
export interface KeybindContribution {
id: string
/** Panel section. Defaults to `view`. */
category?: KeybindCategory
/** Default combos (canonical form, e.g. `mod+shift+\\`). Empty = unbound. */
defaults?: readonly string[]
label: string
run: () => void
}
export function contributedKeybinds(): KeybindContribution[] {
return registry
.getArea(KEYBINDS_AREA)
.map(c => c.data as KeybindContribution)
.filter(k => Boolean(k?.id && k.label) && typeof k?.run === 'function' && !ACTION_BY_ID.has(k.id))
}
/** Built-ins + contributed, one metadata list (panel, bindings, conflicts). */
export function allKeybindActions(): KeybindActionMeta[] {
return [
...KEYBIND_ACTIONS,
...contributedKeybinds().map(k => ({
id: k.id,
category: k.category ?? ('view' as const),
defaults: k.defaults ?? [],
label: k.label
}))
]
}
export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id) ?? allKeybindActions().find(action => action.id === id)
}
/** The contributed handler for an action id (built-ins wire theirs in use-keybinds). */
export function contributedKeybindHandler(id: string): (() => void) | undefined {
return contributedKeybinds().find(k => k.id === id)?.run
}
export type KeybindBindings = Record<string, string[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries(allKeybindActions().map(action => [action.id, [...action.defaults]]))
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
// complete. `keys` are canonical tokens run through `formatCombo` for display
// (single symbols like "@" / "/" pass through unchanged). Categories listed here
// render after the rebindable ones.
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly string[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
{ id: 'composer.help', category: 'composer', keys: ['?'] },
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] },
// ⌘/Ctrl+L moves focus to the composer from anywhere, like the address-bar
// chord in a browser. The row reuses the id of the rebindable soft-focus
// action above. As a result, the panel shows one "Focus composer" label
// for both. The row is fixed because the selection shortcut below uses the
// same chord. Who claims a contested press: see the priority ladder in
// app/chat/composer/focus-chord.ts.
{ id: 'composer.focus', category: 'composer', keys: ['mod+l'] },
// Fixed, context-local shortcuts, listed so users can find them. This row
// uses the same ⌘/Ctrl+L chord as `composer.focus` above. It is the
// selection half of the chord: the selected text (terminal text, preview
// lines) goes into the composer as context.
{ id: 'view.selectionToComposer', category: 'view', keys: ['mod+l'] },
// Terminal clipboard. ⌘C/⌘V on macOS, Ctrl+Shift+C/V elsewhere — matching VS
// Code. Plain Ctrl+C also copies when text is selected (Windows Terminal /
// Tabby behavior); with no selection it stays SIGINT, so it isn't listed.
{ id: 'view.terminalCopy', category: 'view', keys: IS_MAC ? ['mod+c'] : ['mod+shift+c'] },
{ id: 'view.terminalPaste', category: 'view', keys: IS_MAC ? ['mod+v'] : ['mod+shift+v'] },
// Global OS chord registered in main while HUD mode is up.
{ id: 'hud.snapToPointer', category: 'view', keys: ['mod+shift+g'] }
]
+13
View File
@@ -0,0 +1,13 @@
import { isMacPlatform } from '@/lib/platform'
/**
* True when the event is the ⌘/Ctrl+L chord (no shift). The chord routes
* input to the composer: a terminal or preview selection goes in as context,
* and a bare press moves focus. The priority ladder between those consumers
* lives in app/chat/composer/focus-chord.ts.
*/
export function isComposerChord(event: KeyboardEvent): boolean {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
+219
View File
@@ -0,0 +1,219 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
// `IS_MAC` is resolved once at module load from `navigator`, so each platform
// case overrides the platform and re-imports the module fresh.
async function loadCombo(platform: string) {
Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true })
vi.resetModules()
return import('./combo')
}
function keydown(init: KeyboardEventInit): KeyboardEvent {
return new KeyboardEvent('keydown', init)
}
afterEach(() => {
vi.resetModules()
})
describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => {
it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab')
})
it('keeps Cmd as "mod" and distinct from Control on macOS', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k')
expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k')
})
it('uses layout-aware letters for Cmd shortcuts on non-QWERTY layouts', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyI', key: 'c', metaKey: true }))).toBe('mod+c')
expect(comboFromEvent(keydown({ code: 'KeyI', key: 'C', metaKey: true, shiftKey: true }))).toBe('mod+shift+c')
})
it('keeps shifted punctuation anchored to the physical key token', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'Slash', key: '?', metaKey: true, shiftKey: true }))).toBe('mod+shift+/')
})
it('uses layout-aware punctuation for Cmd shortcuts on non-QWERTY layouts', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Dvorak puts "." on the physical QWERTY V key — ⌘. must still reach the
// command center rather than resolving to the physical token.
expect(comboFromEvent(keydown({ code: 'KeyV', key: '.', metaKey: true }))).toBe('mod+.')
expect(comboFromEvent(keydown({ code: 'KeyW', key: ',', metaKey: true }))).toBe('mod+,')
expect(comboFromEvent(keydown({ code: 'BracketLeft', key: '/', metaKey: true }))).toBe('mod+/')
// AZERTY reaches "," from the physical QWERTY M key.
expect(comboFromEvent(keydown({ code: 'KeyM', key: ',', metaKey: true }))).toBe('mod+,')
})
it("keeps digits physical so AZERTY's shifted number row still binds", async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// On AZERTY the unshifted "1" key types "&", and "1" only with Shift held.
// Both must resolve to the same `mod+1` the QWERTY user gets.
expect(comboFromEvent(keydown({ code: 'Digit1', key: '&', metaKey: true }))).toBe('mod+1')
expect(comboFromEvent(keydown({ code: 'Digit1', key: '1', metaKey: true }))).toBe('mod+1')
})
it('falls back to the physical key for glyphs we do not ship as tokens', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Option-modified glyphs, dead keys, and non-Latin scripts are not combo
// tokens, so the physical code keeps the binding reachable.
expect(comboFromEvent(keydown({ code: 'KeyK', key: '˚', metaKey: true, altKey: true }))).toBe('mod+alt+k')
expect(comboFromEvent(keydown({ code: 'KeyN', key: 'Dead', metaKey: true, altKey: true }))).toBe('mod+alt+n')
expect(comboFromEvent(keydown({ code: 'KeyK', key: 'л', metaKey: true }))).toBe('mod+k')
})
it('treats Control as the "mod" accelerator off macOS', async () => {
const { comboFromEvent } = await loadCombo('Win32')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
})
it('recognizes PageUp and PageDown chords', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'PageUp', ctrlKey: true }))).toBe('ctrl+pageup')
expect(comboFromEvent(keydown({ code: 'PageDown', ctrlKey: true }))).toBe('ctrl+pagedown')
})
})
describe('canonicalizeCombo', () => {
it('leaves "ctrl+…" untouched on macOS', async () => {
const { canonicalizeCombo } = await loadCombo('MacIntel')
expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab')
})
it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => {
const { canonicalizeCombo } = await loadCombo('Win32')
expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab')
// Non-ctrl combos are unchanged.
expect(canonicalizeCombo('mod+k')).toBe('mod+k')
})
})
describe('formatCombo — honest Control labels', () => {
it('renders the Control glyph on macOS', async () => {
const { formatCombo, formatModifierToken } = await loadCombo('MacIntel')
expect(formatCombo('ctrl+tab')).toBe('⌃⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥')
expect(formatCombo('mod+enter')).toBe('⌘↵')
expect(formatModifierToken('mod')).toBe('⌘')
})
it.each(['Linux x86_64', 'Win32'])('renders "Ctrl+…" off macOS on %s (base key keeps its glyph)', async platform => {
const { formatCombo, formatModifierToken } = await loadCombo(platform)
expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥')
expect(formatCombo('mod+enter')).toBe('Ctrl+↵')
expect(formatModifierToken('mod')).toBe('Ctrl')
})
it('renders PageUp and PageDown with compact labels', async () => {
const { formatCombo } = await loadCombo('Win32')
expect(formatCombo('ctrl+pageup')).toBe('Ctrl+PgUp')
expect(formatCombo('ctrl+pagedown')).toBe('Ctrl+PgDn')
})
})
describe('actionAllowedInInput', () => {
it('keeps primary-modifier chords global while typing, gating bare/Shift combos to the allowlist', async () => {
const { actionAllowedInInput } = await loadCombo('MacIntel')
// Mod/Ctrl chords are deliberate two-key gestures — they fire even with
// focus in the composer (⌘N, ⌘T, ⌘⇧N, ⌘K, ⌃Tab, …), matching every browser
// and chat app and the pre-#86586 behavior.
expect(actionAllowedInInput('session.new', 'mod+n')).toBe(true)
expect(actionAllowedInInput('session.newTab', 'mod+t')).toBe(true)
expect(actionAllowedInInput('session.newWindow', 'mod+shift+n')).toBe(true)
expect(actionAllowedInInput('session.next', 'ctrl+tab')).toBe(true)
expect(actionAllowedInInput('session.prev', 'ctrl+shift+tab')).toBe(true)
expect(actionAllowedInInput('nav.commandPalette', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.findInPage', 'mod+f')).toBe(true)
expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(true)
expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(true)
// A global action rebound onto a text-editing chord (Ctrl+A select-all,
// Ctrl+E line-end, …) fires deliberately while typing — mod-chords are
// two-key gestures, the pre-#86586 semantic. Unbound chords (the shipped
// default for a/e/u/backspace) never reach this function: the dispatcher
// returns before the gate when no action is bound, so the input keeps its
// native editing behavior out of the box. An editing-chord exclusion set
// would have to dodge shipped defaults (⌘K, ⌘W, ⌘D, ⌘F, ⌘B) or re-break them.
expect(actionAllowedInInput('session.new', 'ctrl+a')).toBe(true)
// Bare modifiers are not real chords — `comboFromEvent` never yields
// them, so a malformed stored binding must not pass the shape-only check.
expect(actionAllowedInInput('session.new', 'mod')).toBe(false)
expect(actionAllowedInInput('session.new', 'ctrl')).toBe(false)
// Bare/Shift-only combos must never hijack typing: a rebound ⌘N → 'n'
// (or the pre-#76185 'shift+n') must not fire while the user types N.
expect(actionAllowedInInput('session.new', 'n')).toBe(false)
expect(actionAllowedInInput('session.new', 'shift+n')).toBe(false)
})
it('leaves text navigation chords with the focused input even when rebound to an allowed action', async () => {
const { actionAllowedInInput } = await loadCombo('Win32')
expect(actionAllowedInInput('session.next', 'mod+right')).toBe(false)
expect(actionAllowedInInput('session.prev', 'mod+left')).toBe(false)
expect(actionAllowedInInput('nav.commandPalette', 'mod+pageup')).toBe(false)
expect(actionAllowedInInput('view.findInPage', 'mod+end')).toBe(false)
})
})
describe('comboFromEvent — IME composition keydowns never resolve to combos (#84957)', () => {
it('returns null while a composition is in progress (isComposing)', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
// Typing 你 with a Chinese IME: the preedit keydowns carry isComposing.
// Before the guard, these canonicalized to combos and fired keybinds
// (e.g. dispatched `session.new` mid-composition).
expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: true, key: 'n' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'Enter', isComposing: true, key: 'Enter' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'Space', isComposing: true, key: ' ' }))).toBeNull()
})
it('returns null for the legacy key="Process" (VK_PROCESSKEY) keydown', async () => {
const { comboFromEvent } = await loadCombo('Win32')
expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Process' }))).toBeNull()
})
it('ignores IME-synthesized modifier-name keys on non-modifier codes', async () => {
const { comboFromEvent } = await loadCombo('Win32')
// Q9 2002-style legacy IMEs synthesize key="Control" with code="KeyW",
// which would otherwise canonicalize to a phantom ctrl+w (close tab).
expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Control' }))).toBeNull()
expect(comboFromEvent(keydown({ code: 'KeyA', key: 'Shift' }))).toBeNull()
})
it('still resolves real combos after composition ends', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: false, key: 'n', metaKey: true }))).toBe('mod+n')
})
})
+285
View File
@@ -0,0 +1,285 @@
// Keybind combo normalization + display.
//
// A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x",
// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on
// both. We derive the base key from `event.key` where the layout matters
// (letters, unshifted punctuation) and from `event.code` otherwise, so a
// binding follows the character the user's layout actually types while Shift
// never mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?").
//
// `ctrl` is physical Control, distinct from `mod`. It only matters on macOS,
// where `mod` is Cmd and Cmd+Tab is OS-reserved — so `ctrl+tab` is literally
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY: Record<string, string> = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Equal: '=',
Minus: '-',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Space: 'space',
Enter: 'enter',
Escape: 'escape',
Backspace: 'backspace',
Tab: 'tab',
PageUp: 'pageup',
PageDown: 'pagedown',
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
'ControlLeft',
'ControlRight',
'MetaLeft',
'MetaRight',
'ShiftLeft',
'ShiftRight'
])
// Modifier names as reported by `event.key` on a bare modifier keydown.
const MODIFIER_KEYS = new Set(['Alt', 'Control', 'Meta', 'Shift'])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Punctuation we ship as combo tokens, derived from CODE_TO_KEY so the two
// can't drift. Named tokens (space, tab, …) are excluded by the length check.
const PUNCTUATION_KEYS = new Set(Object.values(CODE_TO_KEY).filter(token => token.length === 1))
// The layout-aware half of the base key. `event.key` carries the character the
// user's layout actually produces, which is what a binding should match:
//
// - Letters always win, shifted or not — `toLowerCase` normalizes the case.
// - Punctuation only when Shift is UP, because a shifted `event.key` is the
// shifted glyph ("?" for "/"), and combos stay anchored to the unshifted
// token. Shifted punctuation falls through to `event.code` below.
//
// Digits deliberately stay physical: on AZERTY the number row is shifted, so
// `event.key` for the "1" key is "&" and only yields "1" with Shift held —
// `event.code` is what keeps `mod+1` reachable there.
//
// Anything else (Option glyphs like "˚", dead keys, non-Latin scripts) isn't a
// token we ship, so it fails both checks and falls back to the physical code.
function baseKeyFromEventKey(key: string, shiftKey: boolean): string | null {
if (/^[a-z]$/i.test(key)) {
return key.toLowerCase()
}
return !shiftKey && PUNCTUATION_KEYS.has(key) ? key : null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): string | null {
// IME composition (Chinese/Japanese/Korean input): the keydown events
// during composition carry preedit keystrokes and the commit keypress
// (Enter/Space/Shift for candidate selection). Treating them as combos
// fires unrelated keybinds — e.g. typing 你 with a Chinese IME sent a
// keydown that dispatched `session.new` and silently opened a new session.
// Bail out entirely while composing.
if (event.isComposing || event.key === 'Process') {
return null
}
if (MODIFIER_CODES.has(event.code)) {
return null
}
// A keydown whose `key` is a modifier name but whose `code` is a regular
// key is not a real modifier chord — legacy IMEs that synthesize keystrokes
// (Q9 2002 sends key="Control" with code="KeyW") produce these, and they
// would canonicalize to phantom combos (Ctrl+W → close active tab). Ignore.
if (MODIFIER_KEYS.has(event.key)) {
return null
}
const base = baseKeyFromEventKey(event.key, event.shiftKey) ?? baseKeyFromCode(event.code)
if (!base) {
return null
}
const parts: string[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
if (event.metaKey || (event.ctrlKey && !IS_MAC)) {
parts.push('mod')
}
if (event.ctrlKey && IS_MAC) {
parts.push('ctrl')
}
if (event.altKey) {
parts.push('alt')
}
if (event.shiftKey) {
parts.push('shift')
}
parts.push(base)
return parts.join('+')
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
// the same key a live keypress produces. Off macOS, `ctrl+…` and `mod+…` are
// the one Control chord, so a shipped `ctrl+tab` matches a real Control+Tab.
export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
tab: '⇥',
pageup: 'PgUp',
pagedown: 'PgDn',
space: 'Space',
up: '↑',
down: '↓',
left: '←',
right: '→'
}
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
}
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
}
return base.length === 1 ? base.toUpperCase() : base
}
export function formatModifierToken(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
// one cap per token for <KbdGroup>.
export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(formatModifierToken), labelForBase(base)]
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
return IS_MAC ? tokens.join('') : tokens.join('+')
}
// True when focus currently sits inside an element matching `selector`. The
// primitive for focus-scoped shortcuts — e.g. routing ⌘W to whichever surface
// (terminal, preview, …) owns focus.
export function isFocusWithin(selector: string): boolean {
return document.activeElement?.closest(selector) != null
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
// while the user is typing.
export function isEditableTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
return Boolean(
el?.isContentEditable ||
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
const INPUT_SAFE_ACTIONS = new Set([
'composer.modelPicker',
'composer.voice',
'keybinds.openPanel',
'nav.commandPalette',
'session.next',
'session.prev',
'view.findInPage'
])
const TEXT_NAVIGATION_KEYS = new Set(['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown'])
// Only explicit text-entry-safe actions fire while typing. A primary-modifier
// chord (Cmd/Ctrl) is a deliberate two-key gesture that every browser and chat
// app fires even with focus in a text field (⌘N, ⌘T, ⌘K, ⌃Tab…), so those stay
// global — restoring the pre-#86586 behavior. Editing/navigation chords such
// as Ctrl+Arrow/PageUp must stay with the input even if a user rebinds them to
// a global navigation action, and bare/Shift-only combos (typed letters) are
// gated by the allowlist so they never hijack normal typing.
export function actionAllowedInInput(actionId: string, combo: string): boolean {
const base = combo.split('+').pop()
// A bare modifier (no key) is not a real chord — `comboFromEvent` never
// yields one, but reject it here so a malformed stored binding can't pass
// the shape-only mod/ctrl check below.
if (!base || base === 'mod' || base === 'ctrl' || TEXT_NAVIGATION_KEYS.has(base)) {
return false
}
if (/^(?:mod|ctrl)(?:\+|$)/.test(combo)) {
return true
}
return INPUT_SAFE_ACTIONS.has(actionId)
}
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $workspaceIsPage } from '@/app/routes'
import { $activeTreeGroup, $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import { $switcherOpen, closeSwitcher } from '@/store/session-switcher'
import {
composerFocusBlockedBySurface,
composerFocusKeysAllowed,
isActivateOnEnterTarget,
typeToFocusChar,
visibleClarifyCard
} from './composer-focus-keys'
function keydown(init: KeyboardEventInit & { target?: EventTarget }): KeyboardEvent {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
if (init.target) {
Object.defineProperty(event, 'target', { value: init.target })
}
return event
}
describe('isActivateOnEnterTarget', () => {
it('ignores body / null', () => {
expect(isActivateOnEnterTarget(document.body)).toBe(false)
expect(isActivateOnEnterTarget(null)).toBe(false)
})
it('detects buttons and walks to a wrapping activator', () => {
const button = document.createElement('button')
const wrap = document.createElement('div')
wrap.setAttribute('role', 'button')
const child = document.createElement('span')
wrap.append(child)
document.body.append(button, wrap)
expect(isActivateOnEnterTarget(button)).toBe(true)
expect(isActivateOnEnterTarget(child)).toBe(true)
expect(isActivateOnEnterTarget(document.createElement('div'))).toBe(false)
})
})
describe('composerFocusBlockedBySurface', () => {
beforeEach(() => {
$workspaceIsPage.set(false)
closeSwitcher()
document.body.replaceChildren()
})
afterEach(() => {
$workspaceIsPage.set(false)
closeSwitcher()
document.body.replaceChildren()
})
it('is clear on empty chat chrome', () => {
expect(composerFocusBlockedBySurface()).toBe(false)
})
it('blocks dialogs, the session switcher, and full pages', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
document.body.append(dialog)
expect(composerFocusBlockedBySurface()).toBe(true)
document.body.replaceChildren()
$switcherOpen.set(true)
expect(composerFocusBlockedBySurface()).toBe(true)
closeSwitcher()
$workspaceIsPage.set(true)
expect(composerFocusBlockedBySurface()).toBe(true)
})
it('blocks while an overlay covers the chat (composer sits behind it)', () => {
const overlay = document.createElement('div')
overlay.setAttribute('data-overlay-surface', '')
document.body.append(overlay)
expect(composerFocusBlockedBySurface()).toBe(true)
})
it('ignores a clarify card — it yields only its own keys, per-key', () => {
const card = document.createElement('div')
card.setAttribute('data-clarify-choices', '2')
document.body.append(card)
expect(composerFocusBlockedBySurface()).toBe(false)
})
it('blocks when focus is inside a terminal', () => {
const term = document.createElement('div')
term.setAttribute('data-terminal', '')
const inner = document.createElement('div')
term.append(inner)
document.body.append(term)
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => inner })
expect(composerFocusBlockedBySurface()).toBe(true)
Object.defineProperty(document, 'activeElement', {
configurable: true,
get: () => document.body
})
})
})
describe('typeToFocusChar', () => {
it('returns printables (case/symbols via event.key)', () => {
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA' }))).toBe('a')
expect(typeToFocusChar(keydown({ key: 'A', code: 'KeyA', shiftKey: true }))).toBe('A')
expect(typeToFocusChar(keydown({ key: '?', code: 'Slash', shiftKey: true }))).toBe('?')
expect(typeToFocusChar(keydown({ key: ' ', code: 'Space' }))).toBe(' ')
})
it('rejects non-printables and modified chords', () => {
expect(typeToFocusChar(keydown({ key: 'Enter', code: 'Enter' }))).toBeNull()
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', metaKey: true }))).toBeNull()
expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', isComposing: true }))).toBeNull()
})
})
describe('composerFocusKeysAllowed', () => {
// The clarify resolver reads the zone ladder, so these have to start neutral.
const resetZones = () => {
$activeTreeGroup.set(null)
$hoveredTreeGroup.set(null)
}
/** A live clarify card inside its own split zone, both on screen. */
function cardInZone(zone: string): HTMLElement {
const group = document.createElement('div')
group.setAttribute('data-tree-group', zone)
const card = document.createElement('div')
card.setAttribute('data-clarify-choices', '2')
group.append(card)
document.body.append(group)
return card
}
beforeEach(() => {
$workspaceIsPage.set(false)
closeSwitcher()
resetZones()
document.body.replaceChildren()
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(document.body)
})
afterEach(() => {
$workspaceIsPage.set(false)
closeSwitcher()
resetZones()
document.body.replaceChildren()
vi.restoreAllMocks()
})
it('passes rebound chords; allows soft keys on the transcript', () => {
expect(composerFocusKeysAllowed(keydown({ key: 'i', code: 'KeyI', metaKey: true }), 'mod+i')).toBe(true)
expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: document.body }), '/')).toBe(true)
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe(
true
)
expect(composerFocusKeysAllowed(keydown({ key: 'h', code: 'KeyH', target: document.body }), 'type')).toBe(true)
})
it('refuses editables; refuses Enter on buttons but allows / and typing', () => {
const input = document.createElement('input')
const button = document.createElement('button')
document.body.append(input, button)
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: input }), 'type')).toBe(false)
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: button }), 'enter')).toBe(false)
expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: button }), '/')).toBe(true)
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: button }), 'type')).toBe(true)
})
it('refuses when a dialog is open', () => {
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
document.body.append(dialog)
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
})
it('yields only the keys a live clarify card actually binds', () => {
const card = document.createElement('div')
// Two choices → rows A/B plus the "Other" row C; 1/2/3 are the digit twins.
card.setAttribute('data-clarify-choices', '2')
document.body.append(card)
const allowed = (key: string, combo: string) =>
composerFocusKeysAllowed(keydown({ key, target: document.body }), combo)
// The card's own shortcuts win over type-to-focus.
expect(allowed('a', 'type')).toBe(false)
expect(allowed('B', 'type')).toBe(false)
expect(allowed('c', 'type')).toBe(false)
expect(allowed('1', 'type')).toBe(false)
expect(allowed('3', 'type')).toBe(false)
expect(allowed('Enter', 'enter')).toBe(false)
// Everything past the last row is not the card's — typing a real message
// instead of picking an option must still reach the composer.
expect(allowed('d', 'type')).toBe(true)
expect(allowed('z', 'type')).toBe(true)
expect(allowed('4', 'type')).toBe(true)
expect(allowed('?', 'type')).toBe(true)
expect(allowed(' ', 'type')).toBe(true)
})
it('leaves every key alone for a clarify card in a background tab', () => {
const tab = document.createElement('div')
tab.setAttribute('data-pane-hidden', '')
const card = document.createElement('div')
card.setAttribute('data-clarify-choices', '2')
tab.append(card)
document.body.append(tab)
expect(composerFocusKeysAllowed(keydown({ key: 'a', target: document.body }), 'type')).toBe(true)
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', target: document.body }), 'enter')).toBe(true)
})
it('picks the focused zone when a split shows two visible cards', () => {
const cardA = cardInZone('zone-a')
const cardB = cardInZone('zone-b')
// Document order would pin this to zone-a forever, so zone-b's card could
// never take its own shortcut. Both directions are asserted for that reason.
$activeTreeGroup.set('zone-b')
expect(visibleClarifyCard()).toBe(cardB)
$activeTreeGroup.set('zone-a')
expect(visibleClarifyCard()).toBe(cardA)
})
it('lets the hovered zone override the focused one', () => {
const cardA = cardInZone('zone-a')
const cardB = cardInZone('zone-b')
$activeTreeGroup.set('zone-a')
expect(visibleClarifyCard()).toBe(cardA)
// Hover-first, like every tab verb: pointing at the other pane arms its card
// without clicking into it.
$hoveredTreeGroup.set('zone-b')
expect(visibleClarifyCard()).toBe(cardB)
})
it('steps down the ladder instead of answering nothing', () => {
const cardA = cardInZone('zone-a')
const cardB = cardInZone('zone-b')
// Pointer parked on a zone with no pending question — fall through to the
// focused zone rather than returning null.
$hoveredTreeGroup.set('zone-empty')
$activeTreeGroup.set('zone-b')
expect(visibleClarifyCard()).toBe(cardB)
// Neither rung resolves ⇒ document order, deliberately. Returning null here
// would leave Enter doing nothing at all.
resetZones()
expect(visibleClarifyCard()).toBe(cardA)
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', target: document.body }), 'enter')).toBe(false)
})
})
@@ -0,0 +1,193 @@
/**
* Soft focus / type-to-focus for the chat composer.
*
* On empty chat chrome, Enter focuses the composer; printable keys focus and
* type. Bound shortcuts still win via the keybind index. Surfaces that own
* keys (dialogs, menus, terminal, …) are left alone.
*/
import { $workspaceIsPage } from '@/app/routes'
import { queryAllVisible } from '@/components/pane-shell/pane-visibility'
import { $activeTreeGroup, $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import { switcherActive } from '@/store/session-switcher'
import { isEditableTarget, isFocusWithin } from './combo'
/** `composer.focus` defaults that need the surface/target gate. */
export const isComposerFocusSoftCombo = (combo: string) => combo === '/' || combo === 'enter'
const ENTER_ACTIVATES = [
'a[href]',
'button',
'summary',
'input',
'textarea',
'select',
'[contenteditable=""]',
'[contenteditable="true"]',
'[role="button"]',
'[role="checkbox"]',
'[role="combobox"]',
'[role="link"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="treeitem"]'
].join(',')
// Overlays that cover the whole window (portaled to the body, or the overlay
// shell itself) — one anywhere means the composer is behind it.
const BLOCKING_OVERLAY =
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface]'
// Blockers that live INSIDE a chat surface. Inactive tabs stay mounted, so this
// one has to be visible-scoped: a clarify card waiting in a background thread
// must not take the foreground composer's letter keys.
const BLOCKING_IN_SURFACE = '[data-clarify-choices]'
/** The layout-tree zone a pane is rendered in — see `tree/renderer/tree-group`. */
const TREE_GROUP = '[data-tree-group]'
/**
* THE live clarify card — the single card whose shortcuts are armed right now.
*
* Exported because the card's own `keydown` handler has to resolve the same
* element this module does. Both bind the same keys on `window`, so if they
* disagree about which card is live they act for different sessions: this
* resolver yields to the VISIBLE card, while a handler keyed on anything else
* (mount order, say) answers one the user cannot see.
*
* Visible is not by itself an identity, though. A split layout puts two chat
* surfaces on screen at once, so both cards clear the hidden-pane filter and
* taking the first remaining DOM match would pick document ORDER — the earlier
* zone would own the keys permanently and the other visible card could never
* receive its shortcut. Break that tie on the SAME hovered → focused zone
* ladder every tab verb already runs (`tabTargetGroup` in `tree/store`,
* mirrored for the model hotkey by `composerTargetInHoveredZone`, #74447)
* rather than inventing a second notion of which surface is "the" one.
*
* The last resort stays document order rather than null on purpose: when
* neither rung names a zone that holds a card (pointer off every zone, nothing
* interacted with yet) returning null would leave Enter doing nothing at all,
* which is a worse regression than the single-card behaviour it replaces.
*/
export const visibleClarifyCard = (): HTMLElement | null => {
const cards = queryAllVisible<HTMLElement>(BLOCKING_IN_SURFACE)
// Overwhelmingly the common case — nothing to disambiguate, no store read.
if (cards.length < 2) {
return cards[0] ?? null
}
for (const zone of [$hoveredTreeGroup.get(), $activeTreeGroup.get()]) {
const card = zone ? cards.find(el => el.closest<HTMLElement>(TREE_GROUP)?.dataset.treeGroup === zone) : undefined
if (card) {
return card
}
}
return cards[0]
}
/** True when the focused control would normally handle Enter itself. */
export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES))
}
/**
* True when a live clarify card binds THIS key, so type-to-focus must yield it.
*
* The card owns Enter plus the shortcuts it actually renders — `1..N+1` and
* `A..` for its N choices and the trailing "Other" row. It does NOT own the
* rest of the alphabet: typing a real message instead of picking an option is a
* legitimate answer ("none of these"), and blanket-blocking every printable
* left the user unable to start that message at all — the first letter vanished
* and the composer never focused. Out-of-range keys fall through to the
* composer, which skips the question on send.
*
* The choice count rides in the attribute's value, so this stays a DOM read
* with no store coupling.
*/
export function clarifyCardOwnsKey(event: KeyboardEvent): boolean {
const card = visibleClarifyCard()
if (!card) {
return false
}
if (event.key === 'Enter') {
return true
}
// "Other" is the row past the last choice, hence the +1.
const rows = Number(card.getAttribute('data-clarify-choices')) + 1
if (!Number.isFinite(rows)) {
return false
}
const key = event.key.toLowerCase()
if (key.length !== 1) {
return false
}
const index = /^[1-9]$/.test(key) ? Number(key) - 1 : key >= 'a' && key <= 'z' ? key.charCodeAt(0) - 97 : -1
return index >= 0 && index < rows
}
/**
* Dialogs, menus, terminal, full pages, session switcher, and any open overlay —
* they keep their keys, so type-to-focus / soft `/` / Enter stand down rather
* than stealing keystrokes those surfaces own (or leaking them into the composer
* mounted behind an overlay). A live clarify card is handled per-key by
* `clarifyCardOwnsKey`, not here — it only owns its own shortcuts.
*/
export function composerFocusBlockedBySurface(): boolean {
return (
switcherActive() ||
$workspaceIsPage.get() ||
isFocusWithin('[data-terminal]') ||
Boolean(document.querySelector(BLOCKING_OVERLAY))
)
}
/** Printable `event.key` for type-to-focus, or null (modifiers / non-printables / IME). */
export function typeToFocusChar(event: KeyboardEvent): string | null {
if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey || event.altKey) {
return null
}
// Length 1 ⇒ letter/digit/punct/space; Enter/Tab/Arrows/Dead/F-keys are longer.
return event.key.length === 1 ? event.key : null
}
/**
* Whether soft focus / type-to-focus may run.
* `combo` is `/` | `enter` | `'type'` (unbound printable); other chords pass.
*/
export function composerFocusKeysAllowed(event: KeyboardEvent, combo: string): boolean {
if (combo !== 'type' && !isComposerFocusSoftCombo(combo)) {
return true
}
if (
event.defaultPrevented ||
event.isComposing ||
isEditableTarget(event.target) ||
composerFocusBlockedBySurface() ||
clarifyCardOwnsKey(event)
) {
return false
}
return !(combo === 'enter' && isActivateOnEnterTarget(event.target))
}
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff. Show more