feat: more ui qa
This commit is contained in:
@@ -318,7 +318,11 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
||||
role,
|
||||
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
|
||||
createdAt,
|
||||
status: message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
status: message.error
|
||||
? { type: 'incomplete', reason: 'error', error: message.error }
|
||||
: message.pending
|
||||
? { type: 'running' }
|
||||
: { type: 'complete', reason: 'stop' },
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
|
||||
@@ -143,6 +143,7 @@ describe('external link helpers', () => {
|
||||
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/'
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ export function ExternalLink({
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current', className)}
|
||||
className={cn('font-semibold text-foreground underline underline-offset-4 decoration-current/20', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -180,9 +180,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
||||
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[])
|
||||
: []
|
||||
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
|
||||
@@ -201,12 +199,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
||||
|
||||
// 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'
|
||||
) {
|
||||
if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') {
|
||||
scope = parent as Element
|
||||
parent = parents[parents.length - 2]
|
||||
displayMode = true
|
||||
@@ -253,10 +246,7 @@ function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
|
||||
* wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`.
|
||||
*/
|
||||
export function createMemoizedMathPlugin(config: MathPluginConfig = {}) {
|
||||
const remarkPlugin: Pluggable = [
|
||||
remarkMath,
|
||||
{ singleDollarTextMath: config.singleDollarTextMath ?? false }
|
||||
]
|
||||
const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }]
|
||||
|
||||
const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor })
|
||||
|
||||
|
||||
@@ -310,7 +310,9 @@ const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g
|
||||
const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g
|
||||
|
||||
function rewriteLatexBracketDelimiters(text: string): string {
|
||||
return text.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`).replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
|
||||
return text
|
||||
.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`)
|
||||
.replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
|
||||
}
|
||||
|
||||
// Escape `$<digit>` patterns so they don't get eaten as math delimiters.
|
||||
@@ -340,14 +342,19 @@ export function preprocessMarkdown(text: string): string {
|
||||
.split(CODE_FENCE_SPLIT_RE)
|
||||
.map(part => {
|
||||
// Fence blocks pass through untouched.
|
||||
if (/^(?:```|~~~)/.test(part)) {return part}
|
||||
if (/^(?:```|~~~)/.test(part)) {
|
||||
return part
|
||||
}
|
||||
|
||||
// Whitespace-only segments (e.g. the `\n\n` between two adjacent
|
||||
// fences) must NOT go through stripPreviewTargets — its internal
|
||||
// .trim() would collapse them to '' and glue the surrounding
|
||||
// fences together, producing things like ``````math which the
|
||||
// markdown parser then reads as a single 6-backtick block.
|
||||
if (!part.trim()) {return part}
|
||||
if (!part.trim()) {
|
||||
return part
|
||||
}
|
||||
|
||||
// Preserve leading/trailing whitespace around the prose body so
|
||||
// that fence-prose-fence sequences keep their blank-line gaps.
|
||||
// stripPreviewTargets internally calls .trim() on its result for
|
||||
|
||||
@@ -4,16 +4,16 @@ import { isProviderSetupErrorMessage } from './provider-setup-errors'
|
||||
|
||||
describe('isProviderSetupErrorMessage', () => {
|
||||
it('matches generic missing-provider copy', () => {
|
||||
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(true)
|
||||
expect(isProviderSetupErrorMessage('No inference provider configured. Run `hermes model` to choose one.')).toBe(
|
||||
true
|
||||
)
|
||||
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
|
||||
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match non-provider runtime failures', () => {
|
||||
expect(
|
||||
isProviderSetupErrorMessage(
|
||||
'Selected runtime is not available. setup.status reports configured credentials.'
|
||||
)
|
||||
isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ function parseArray(value: unknown[]): TodoItem[] {
|
||||
if (!isRecord(item) || !isStatus(item.status)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const id = String(item.id ?? '').trim()
|
||||
const content = String(item.content ?? '').trim()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// mode still gets the raw JSON section.
|
||||
|
||||
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
|
||||
|
||||
const PRIORITY_KEYS = [
|
||||
'title',
|
||||
'name',
|
||||
@@ -17,6 +18,7 @@ const PRIORITY_KEYS = [
|
||||
'summary',
|
||||
'description'
|
||||
] as const
|
||||
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
@@ -66,6 +68,7 @@ function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const lines = t.split('\n')
|
||||
let text = lines.slice(0, maxLines).join('\n')
|
||||
const clipped = lines.length > maxLines || text.length > maxChars
|
||||
@@ -187,11 +190,13 @@ function formatFieldValue(value: unknown, depth: number): string {
|
||||
if (!v.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const scalars = v.map(summarizeScalar).filter(Boolean)
|
||||
|
||||
if (scalars.length === v.length && v.length <= 4) {
|
||||
return clipInline(scalars.join(', '))
|
||||
}
|
||||
|
||||
const first = summarizeListItem(v[0], depth + 1)
|
||||
|
||||
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
|
||||
@@ -207,16 +212,21 @@ function formatFieldValue(value: unknown, depth: number): string {
|
||||
// "Returned N items" / "0 items" / "Returned an empty object" are all
|
||||
// noise — better to render nothing and let the title carry the signal.
|
||||
function formatArraySummary(value: unknown[], depth: number): string {
|
||||
if (!value.length) return ''
|
||||
if (!value.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const max = 6
|
||||
|
||||
const lines = value
|
||||
.slice(0, max)
|
||||
.map(item => summarizeListItem(item, depth + 1))
|
||||
.filter(Boolean)
|
||||
.map(l => `- ${l}`)
|
||||
|
||||
if (!lines.length) return ''
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value.length > max) {
|
||||
const remaining = value.length - max
|
||||
@@ -228,7 +238,10 @@ function formatArraySummary(value: unknown[], depth: number): string {
|
||||
|
||||
function formatRecordSummary(record: Json, depth: number): string {
|
||||
const keys = Object.keys(record)
|
||||
if (!keys.length) return ''
|
||||
|
||||
if (!keys.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (depth <= 2) {
|
||||
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
|
||||
@@ -249,6 +262,7 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
if (!v) {
|
||||
continue
|
||||
}
|
||||
|
||||
lines.push(`- ${titleCase(k)}: ${v}`)
|
||||
|
||||
if (lines.length >= max) {
|
||||
@@ -256,7 +270,9 @@ function formatRecordSummary(record: Json, depth: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (!lines.length) return ''
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (candidates.length > lines.length) {
|
||||
const remaining = candidates.length - lines.length
|
||||
@@ -270,6 +286,7 @@ function formatSummaryValue(value: unknown, depth: number): string {
|
||||
if (depth > 4) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const v = norm(value)
|
||||
|
||||
if (typeof v === 'string') {
|
||||
@@ -383,11 +400,13 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
||||
if (depth > 5) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const v = norm(value)
|
||||
|
||||
if (!v || typeof v !== 'object' || seen.has(v)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
seen.add(v)
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
@@ -408,6 +427,7 @@ function findNestedError(value: unknown, depth: number, seen: Set<unknown>): str
|
||||
if (!hasMeaningfulErrorValue(record[k])) {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = valueErrorText(record[k])
|
||||
|
||||
if (text) {
|
||||
|
||||
Reference in New Issue
Block a user