Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }) : []
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('/')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,.;:—-]+$/, '')}…`
|
||||
}
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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', [
|
||||
'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!  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: '', 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'] }
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user