Files
hermes-workspace/src/components/prompt-kit/markdown.tsx
Eric 4f75b5835c fix: batch remaining workspace bugfix slices (#483)
* chore: preserve pnpm approved builds

* fix(router): support runtime basepath override for reverse-proxy hosting

The TanStack router was created without a `basepath` option, so the same
built bundle could not be hosted under a path prefix (e.g. behind a
reverse proxy that mounts the app at `/workspaces/<id>/`). Hard refreshes
appeared to work because SSR runs at the proxy-stripped path, but
client-side navigation to dynamic routes such as `/chat/$sessionKey`
silently fell through to the catch-all `/$` route — rendering the
"404 — Not Found" page from inside the SPA.

Read an optional `window.__HERMES_WORKSPACE_BASEPATH__` global and pass
it through to `createRouter`. When unset, behavior is unchanged
(`basepath: '/'`). The value is normalized so callers can pass either
`/workspaces/abc`, `workspaces/abc`, or `/workspaces/abc/` without
upsetting TanStack's pathname matching.

This lets hosting layers inject a tiny inline script before the bundle
loads to mount the app at any path, without rebuilding.

* fix(chat): prevent stale thinking state after page refresh (closes #449)

Root cause: sessionStorage 'waiting' flags persisted across page refreshes
even for completed conversations. The Zustand store restored these stale
entries on mount, and the active-run API check cleared them async —
but there was a visible render window where the UI showed 'thinking'.

Fix:
1. Added activeRunCheckDone state that gates the waitingForResponse memo.
   While the active-run API check is pending, stale restored state is
   not trusted — the thinking indicator stays hidden until verification.
2. Added onCheckComplete callback to useActiveRunCheck hook that fires
   after the API check finishes (success or error), unblocking the gate.
3. Added a useEffect that detects restored stale waiting state and sets
   pendingVerifySessionKeyRef so the gate only applies to the key that
   needs verification — not to genuine active streams.

Test: e2e/chat-thinking-state.spec.ts injects a stale sessionStorage
entry before page load, then verifies no thinking indicator appears
and the stale entry is cleaned up by the API check.

* fix(chat): eliminate duplicate messages flicker on stream completion (closes #441)

Root cause: onDone handler used queryClient.invalidateQueries() which
triggers an async refetch. During the refetch window, mergeHistoryMessages
ran with stale cache data + realtime buffer, producing visible duplicates
(extra user message + blank line) for 1-2 seconds until refetch completed.

Fix: Directly merge realtime buffer into history cache via setQueryData(),
then clear buffer synchronously. Background refetch runs after for
consistency but doesn't block rendering.

* fix: restore hermes-config and config-patch API routes

The Aurora rename migration (efcb7d14) renamed hermes-config.ts to
claude-config.ts, but the frontend and routeTree.gen.ts still reference
the original paths. This caused all /api/hermes-config and /api/config-patch
requests to fall through to the SPA HTML fallback, breaking config saves
from the settings dialog and provider wizard with 'Failed to save' errors.

Restored by creating thin route files that delegate to the existing
handleHermesConfigGet/handleHermesConfigPatch handlers from
src/server/hermes-config-route.ts.

Fixes the settings dialog (hermes-config GET/PATCH) and provider wizard
(config-patch POST) config save flows.

* fix(server): add essential env vars to terminal session

* fix(swarm): worker card shows stale state after task completes

deriveWorkerState derived the badge from currentTask title substring
matching and markCheckpointResult never cleared currentTask on terminal
checkpoints, so a finished worker's card stayed permanently 'working'.

- swarm-dispatch.ts: clear currentTask on terminal checkpoint
  (checkpointStatus !== 'in_progress'), matching conductor-stop's reset
- operational-worker-card.tsx: deriveWorkerState reads authoritative
  checkpointStatus/state first, title heuristic only while in_progress
- swarm2-screen.tsx: pass checkpointStatus/state into the card

* fix(portable-history): replay authenticated portable chat history

* fix(config): keep legacy claude-config shim on shared handlers

* fix: harden splash hydration and docker uid mapping

* fix: keep seen update notes dismissed

* feat: consolidate workspace state under configurable state directory (closes #439)

Adds HERMES_WORKSPACE_STATE_DIR env var support, consolidating 5
scattered state files under a single configurable directory.

Changes:
- New src/server/workspace-state-dir.ts with getStateDir() utility
  honoring HERMES_WORKSPACE_STATE_DIR → HERMES_HOME/workspace →
  CLAUDE_HOME/workspace → ~/.hermes/workspace (fallback chain)
- Updated gateway-capabilities.ts (workspace-overrides.json)
- Updated mcp-presets-store.ts (mcp-presets.json)
- Updated mcp-hub-sources-store.ts (mcp-hub-sources.json)
- Updated mcp-tools-cache.ts (cache/mcp-tools.json)
- Updated knowledge-config.ts (knowledge-config.json)
- Removed 5 duplicated hermesHome() functions, replaced with shared
  getStateDir() import

Test: 6 vitest unit tests covering all env var priority combinations
(cherry picked from commit d6bebe0614b0c7b9015bac5e35d315a8450ac146)

* fix(conductor): surface native-swarm progress and harden worker startup

* feat(chat): safely render HTML message markup

* fix(chat): surface installed skills in slash autocomplete

* fix: add swarm runtime reset endpoint

* fix(conductor): mobile rendering — add overflow-y-auto, mobile bottom padding, OfficeView responsive height, tabbar fix

* fix(send-stream): preserve runs on client disconnect

* fix(profiles): skip profiles/default duplicate card

* fix: accept HERMES_AGENT_PATH override

* fix(profiles): allow disabling sticky active_profile writes

* fix: preserve workspace chat session routing

* fix(portable-history): skip replay when gateway session continuity is available

---------

Co-authored-by: Hermes Agent <hermes-agent@local.invalid>
Co-authored-by: jack <jack@hijak.dev>
Co-authored-by: Waylon Kenning <waylonkenning@Waylons-MacBook-Pro.local>
Co-authored-by: Michael Rodriguez <michael@rivercity-industries.com>
Co-authored-by: Vu Tran <baysao@gmail.com>
Co-authored-by: iltaek <iltaekkwon@gmail.com>
Co-authored-by: Aurora release bot <release@outsourc-e.com>
Co-authored-by: jonathanmalkin <jonathan.d.malkin@gmail.com>
Co-authored-by: KT-Hermes <ktadmin@kt-bot2.tekeis.net>
2026-05-19 16:27:10 -04:00

503 lines
14 KiB
TypeScript

import { marked } from 'marked'
import { createContext, memo, useContext, useId, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './code-block'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
/**
* Rewrite Workspace-local `MEDIA:<path>` tokens emitted by Hermes Agent to the
* authenticated media endpoint. Messaging bridges intercept MEDIA tags before
* rendering; the web chat sees raw markdown/HTML and needs this client-side
* rewrite so browsers can load the file through Workspace instead of trying to
* resolve a local filesystem path directly.
*/
export function rewriteLocalMediaSources(content: string): string {
const rewritePath = (rawPath: string): string | null => {
const path = rawPath.trim()
if (!path || /^https?:\/\//i.test(path)) return null
return `/api/media?path=${encodeURIComponent(path)}`
}
const markdownImage = /(!\[[^\]]*\]\()MEDIA:([^\)\s]+)(\))/g
const withMarkdownImages = content.replace(
markdownImage,
(_match, prefix: string, mediaPath: string, suffix: string) => {
const rewritten = rewritePath(mediaPath)
return rewritten ? `${prefix}${rewritten}${suffix}` : `${prefix}MEDIA:${mediaPath}${suffix}`
},
)
const htmlImage = /(<img\b[^>]*\bsrc=)(["'])MEDIA:([^"']+)\2/gi
return withMarkdownImages.replace(
htmlImage,
(_match, prefix: string, quote: string, mediaPath: string) => {
const rewritten = rewritePath(mediaPath)
return rewritten
? `${prefix}${quote}${rewritten}${quote}`
: `${prefix}${quote}MEDIA:${mediaPath}${quote}`
},
)
}
export type MarkdownProps = {
children: string
id?: string
className?: string
components?: Partial<Components>
}
function parseMarkdownIntoBlocks(markdown: string): Array<string> {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
}
function extractLanguage(className?: string): string {
if (!className) return 'text'
const match = className.match(/language-(\w+)/)
return match ? match[1] : 'text'
}
type TableRenderContextValue = {
headersRef: React.MutableRefObject<Array<string>>
columnIndexRef: React.MutableRefObject<number>
collectingHeaderRef: React.MutableRefObject<boolean>
}
const TableRenderContext = createContext<TableRenderContextValue | null>(null)
function useTableRenderContext() {
return useContext(TableRenderContext)
}
function textFromNode(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map((item: React.ReactNode) => textFromNode(item)).join('')
}
if (node && typeof node === 'object' && 'props' in node) {
const element = node as { props: { children?: React.ReactNode } }
return textFromNode(element.props.children)
}
return ''
}
function slugifyHeading(children: React.ReactNode): string {
const raw = textFromNode(children)
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
return raw.length > 0 ? raw : 'section'
}
const INITIAL_COMPONENTS: Partial<Components> = {
code: function CodeComponent({ className, children }) {
const isInline = !className?.includes('language-')
if (isInline) {
return (
<code className="rounded bg-primary-100 px-1.5 py-0.5 text-[0.9em] font-mono text-primary-900 border border-primary-200">
{children}
</code>
)
}
const language = extractLanguage(className)
return (
<CodeBlock
content={String(children ?? '')}
language={language}
className="w-full my-2"
/>
)
},
pre: function PreComponent({ children }) {
return <>{children}</>
},
h1: function H1Component({ children }) {
return (
<h1 className="mt-5 mb-2 text-2xl leading-tight font-medium text-primary-950 text-balance first:mt-0">
{children}
</h1>
)
},
h2: function H2Component({ children }) {
const id = slugifyHeading(children)
return (
<h2
id={id}
className="mt-5 mb-2 text-xl leading-tight font-medium text-primary-950 text-balance first:mt-0"
>
<a
href={`#${id}`}
className="group/heading inline-flex items-center gap-1 no-underline"
>
<span>{children}</span>
<span
aria-hidden="true"
className="text-primary-500 opacity-0 transition-opacity group-hover/heading:opacity-100"
>
#
</span>
</a>
</h2>
)
},
h3: function H3Component({ children }) {
const id = slugifyHeading(children)
return (
<h3
id={id}
className="mt-4 mb-1.5 text-lg leading-tight font-medium text-primary-950 text-balance first:mt-0"
>
<a
href={`#${id}`}
className="group/heading inline-flex items-center gap-1 no-underline"
>
<span>{children}</span>
<span
aria-hidden="true"
className="text-primary-500 opacity-0 transition-opacity group-hover/heading:opacity-100"
>
#
</span>
</a>
</h3>
)
},
h4: function H4Component({ children }) {
return (
<h4 className="mt-4 mb-1.5 text-base leading-tight font-medium text-primary-950 text-balance first:mt-0">
{children}
</h4>
)
},
h5: function H5Component({ children }) {
return (
<h5 className="mt-3.5 mb-1 text-sm leading-tight font-medium text-primary-950 text-balance first:mt-0">
{children}
</h5>
)
},
h6: function H6Component({ children }) {
return (
<h6 className="mt-3.5 mb-1 text-sm leading-tight font-medium text-primary-900 text-balance first:mt-0">
{children}
</h6>
)
},
p: function PComponent({ children }) {
return (
<p className="text-primary-950 text-pretty leading-relaxed">{children}</p>
)
},
ul: function UlComponent({ children }) {
return (
<ul className="ml-4 list-disc text-primary-950 marker:text-primary-400">
{children}
</ul>
)
},
ol: function OlComponent({ children }) {
return (
<ol className="ml-4 list-decimal text-primary-950 marker:text-primary-500">
{children}
</ol>
)
},
li: function LiComponent({ children }) {
return <li className="leading-relaxed">{children}</li>
},
a: function AComponent({ children, href }) {
if (!href) {
return <span className="text-primary-950">{children}</span>
}
return (
<a
href={href}
className="text-primary-950 underline decoration-primary-300 underline-offset-4 transition-colors hover:text-primary-950 hover:decoration-primary-500"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
},
img: function ImgComponent({ src, alt, ...props }) {
if (!src) {
return null
}
return <img src={src} alt={alt ?? ''} {...props} />
},
blockquote: function BlockquoteComponent({ children }) {
return (
<blockquote className="border-l-2 border-primary-300 pl-4 text-primary-900 italic">
{children}
</blockquote>
)
},
strong: function StrongComponent({ children }) {
return <strong className="font-medium text-primary-950">{children}</strong>
},
em: function EmComponent({ children }) {
return <em className="italic text-primary-950">{children}</em>
},
hr: function HrComponent() {
return <hr className="my-3 border-primary-200" />
},
table: function TableComponent({ children }) {
const headersRef = useRef<Array<string>>([])
const columnIndexRef = useRef(0)
const collectingHeaderRef = useRef(false)
return (
<TableRenderContext.Provider
value={{ headersRef, columnIndexRef, collectingHeaderRef }}
>
<div className="my-3 max-w-full overflow-x-auto rounded-lg border border-primary-200 bg-primary-50/20">
<table className="w-full min-w-max border-collapse text-sm sm:min-w-full tabular-nums">
{children}
</table>
</div>
</TableRenderContext.Provider>
)
},
thead: function TheadComponent({ children }) {
const context = useTableRenderContext()
if (context) {
context.collectingHeaderRef.current = true
context.columnIndexRef.current = 0
context.headersRef.current = []
}
return (
<thead className="sticky top-0 z-10 border-b border-primary-200 bg-primary-100/95 backdrop-blur-sm max-sm:hidden">
{children}
</thead>
)
},
tbody: function TbodyComponent({ children }) {
const context = useTableRenderContext()
if (context) {
context.collectingHeaderRef.current = false
context.columnIndexRef.current = 0
}
return (
<tbody className="divide-y divide-primary-100 max-sm:block max-sm:divide-y-0">
{children}
</tbody>
)
},
tr: function TrComponent({ children }) {
const context = useTableRenderContext()
if (context) {
context.columnIndexRef.current = 0
}
return (
<tr className="odd:bg-primary-50/60 even:bg-primary-100/20 transition-colors hover:bg-primary-100/45 max-sm:mb-3 max-sm:block max-sm:overflow-hidden max-sm:rounded-lg max-sm:border max-sm:border-primary-200 max-sm:bg-primary-50">
{children}
</tr>
)
},
th: function ThComponent({ children }) {
const context = useTableRenderContext()
if (context) {
const index = context.columnIndexRef.current
context.columnIndexRef.current += 1
if (context.collectingHeaderRef.current) {
context.headersRef.current[index] = textFromNode(children).trim()
}
}
return (
<th className="px-3 py-2 text-left font-medium text-primary-950 whitespace-nowrap">
{children}
</th>
)
},
td: function TdComponent({ children }) {
const context = useTableRenderContext()
let label = ''
if (context) {
const index = context.columnIndexRef.current
context.columnIndexRef.current += 1
label = context.headersRef.current[index] ?? `Column ${index + 1}`
}
return (
<td
data-label={label}
className="px-3 py-2 text-primary-950 align-top max-sm:grid max-sm:grid-cols-[minmax(0,9rem)_1fr] max-sm:gap-3 max-sm:border-b max-sm:border-primary-100 max-sm:px-3 max-sm:py-2 max-sm:last:border-b-0 max-sm:before:content-[attr(data-label)] max-sm:before:text-xs max-sm:before:font-medium max-sm:before:text-primary-700"
>
{children}
</td>
)
},
tfoot: function TfootComponent({ children }) {
return (
<tfoot className="border-t border-primary-200 bg-primary-100/40">
{children}
</tfoot>
)
},
}
const HTML_SANITIZE_SCHEMA = {
tagNames: [
'a',
'abbr',
'article',
'b',
'bdi',
'blockquote',
'br',
'caption',
'center',
'cite',
'code',
'col',
'colgroup',
'data',
'dd',
'del',
'details',
'dfn',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'main',
'mark',
'nav',
'ol',
'p',
'pre',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'section',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'time',
'tr',
'u',
'ul',
'var',
'wbr',
],
attributes: {
'*': ['className', 'class', 'title', 'lang', 'dir'],
a: ['href', 'target', 'rel', 'download'],
img: ['src', 'alt', 'width', 'height', 'loading'],
td: ['colspan', 'rowspan', 'headers'],
th: ['colspan', 'rowspan', 'headers', 'scope'],
col: ['span'],
colgroup: ['span'],
ol: ['start', 'type'],
li: ['value'],
details: ['open'],
time: ['datetime'],
data: ['value'],
del: ['datetime'],
ins: ['datetime'],
},
protocols: {
a: { href: ['http', 'https', 'mailto', 'tel'] },
img: { src: ['http', 'https', 'data'] },
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
components = INITIAL_COMPONENTS,
}: {
content: string
components?: Partial<Components>
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, HTML_SANITIZE_SCHEMA]]}
components={components}
>
{content}
</ReactMarkdown>
)
},
function propsAreEqual(prevProps, nextProps) {
return prevProps.content === nextProps.content
},
)
MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock'
function MarkdownComponent({
children,
id,
className,
components = INITIAL_COMPONENTS,
}: MarkdownProps) {
const generatedId = useId()
const blockId = id ?? generatedId
const blocks = useMemo(
() => parseMarkdownIntoBlocks(rewriteLocalMediaSources(children)),
[children],
)
return (
<div
className={cn(
'flex flex-col gap-2 break-words overflow-hidden',
className,
)}
>
{blocks.map((block, index) => (
<MemoizedMarkdownBlock
key={`${blockId}-block-${index}`}
content={block}
components={components}
/>
))}
</div>
)
}
const Markdown = memo(MarkdownComponent)
Markdown.displayName = 'Markdown'
export { Markdown }