Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
- Settings → Appearance → Colors offers **Forest**, a low-saturation green-grey palette for long
sessions, in dark and light, with matching terminal and title-bar colors.
([#775](https://github.com/devswha/herdr-web-ui/pull/775) by @vibewriter-hand)
- A `mermaid` code block in the chat is drawn as a diagram, with a full-screen lightbox to zoom and
pan it, and the file viewer renders markdown files with local images; larger images are offered
as downloads. ([#616](https://github.com/devswha/herdr-web-ui/pull/616) by @lyonbot)
- Pin agents to the top of the Agents list with the star beside each row; pins keep their order across reloads. ([#778](https://github.com/devswha/herdr-web-ui/pull/778) by @vibewriter-hand)
- An OpenCode pane on a 1.x store (`session`/`message`/`part`) reads as a conversation again, where
the chat used to keep the terminal's text: prompts with the pictures pasted in them, each answer's
Expand Down
639 changes: 639 additions & 0 deletions THIRD_PARTY_NOTICES.md

Large diffs are not rendered by default.

233 changes: 233 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@xterm/xterm": "5.5.0",
"katex": "^0.18.9",
"lucide-react": "^1.47.0",
"mermaid": "^12.1.0",
Comment thread
devswha marked this conversation as resolved.
Resolved
"qrcode-generator": "^2.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
2 changes: 1 addition & 1 deletion scripts/file-viewer-mobile-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async function checkLayout(): Promise<void> {
return new Response(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="teal"/></svg>`,
{ headers: { "content-type": "image/svg+xml" } });
}
return new Response(`<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><link rel="stylesheet" href="/styles.css?safe=${url.searchParams.get("safe") ?? "none"}"></head><body><div id="root"></div><script src="/entry.js"></script></body></html>`,
return new Response(`<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><link rel="stylesheet" href="/styles.css?safe=${url.searchParams.get("safe") ?? "none"}"></head><body><div id="root"></div><script type="module" src="/entry.js"></script></body></html>`,
{ headers: { "content-type": "text/html" } });
} });
browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] });
Expand Down
2 changes: 2 additions & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an
- Dictation (lib/voice.ts) only hands text to the caller to insert at the caret; it never sends.
- An alert chime before the first tap or key (`unlockAlertSound`) is skipped, never queued to sound late.
- Never import KaTeX statically: Markdown.tsx loads lib/katex.ts with the first expression (it is a fifth of the app's script).
- Never import Mermaid statically: Markdown.tsx loads lib/mermaid.ts with the first ```mermaid fence (its own 685 kB chunk). It keeps `securityLevel: "strict"`; a block that does not parse stays a code block.
- Markdown draws images only under a `MarkdownImageContext` (components/Markdown.tsx); FileViewer provides it for .md files through lib/markdownImages.ts, so a path reads through `/api/fs/file`. Chat has no provider: the image stays the `!` + link it always was (the CSP allows own-origin images only).

## REMOTE TARGET OWNERSHIP
- `MachineSessionsProvider` owns one WebSocket per visited PC while the app is unlocked. Pane mounts only own their attach/watch and renderer subscriptions: leaving a pane detaches without closing the PC connection or suspending pending proof. `MachineSession` alone publishes pending receipts and suspends their scope on real disconnect/close. Do not infer held state locally or replay input when a pane returns.
Expand Down
29 changes: 29 additions & 0 deletions src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,35 @@
margin-bottom: var(--space-3);
}

.markdown-image {
max-width: 100%;
height: auto;
}

.markdown-mermaid-frame {
position: relative;
margin-bottom: var(--space-3);
}

.markdown-mermaid {
display: flex;
justify-content: center;
overflow: hidden;
cursor: zoom-in;
}

.markdown-mermaid svg {
max-width: 100%;
max-height: 24rem;
height: auto;
}

.markdown-mermaid-zoom {
position: absolute;
top: var(--space-1);
right: var(--space-1);
}

.markdown p,
.markdown-h1,
.markdown-h2,
Expand Down
6 changes: 6 additions & 0 deletions src/components/FileViewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@
background: var(--pdf-page);
}

.file-viewer-markdown {
align-self: stretch;
min-width: 0;
padding: var(--space-3);
}

.file-viewer-text {
align-self: stretch;
margin: 0;
Expand Down
19 changes: 13 additions & 6 deletions src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Download, ExternalLink, X } from "lucide-react";
import "./FileViewer.css";
import { DirectoryBrowser } from "./DirectoryBrowser.tsx";
import { Markdown, MarkdownImageContext } from "./Markdown.tsx";

import type { FileInfo } from "../../shared/protocol.ts";
import { ApiError } from "../lib/api.ts";
import { formatBytes } from "../lib/bridgeProgress.ts";
import { LOCAL_MACHINE } from "../../shared/machines.ts";
import { useMachineApi, useMachineId } from "../lib/machineContext.tsx";
import { useT } from "../lib/i18n.ts";
import { MAX_INLINE_IMAGE_BYTES, markdownImagePreview } from "../lib/markdownImages.ts";
import { nativeModalOver, useFocusTrap } from "../lib/useFocusTrap.ts";
import { readFilePreviewText } from "../lib/filePreviewText.ts";

/** Bigger images are offered as a download: a phone decodes an image whole. */
const MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024;
/** Text shows its first part: the rest is a download away. */
const TEXT_PREVIEW_BYTES = 256 * 1024;

Expand All @@ -30,7 +30,7 @@ export interface FileViewerProps {

/**
* A file an agent wrote, opened in the browser: images, video and audio (streamed, so they
* play and seek at once), PDFs, and the start of a text file. Anything can be downloaded.
* play and seek at once), PDFs, and the start of a text file (markdown drawn, its images read through the file endpoint). Anything can be downloaded.
*/
export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActive = true }: FileViewerProps) {
const t = useT();
Expand Down Expand Up @@ -96,6 +96,11 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv

// the file found (a bare name may have been found deeper in the folder), else as asked
const url = fileUrl(info?.path ?? path, paneId);
const filePath = info?.path ?? path;
// images of a markdown file load through the file endpoint, as the file itself does
const imagePreview = useCallback((src: string) => markdownImagePreview(
src, filePath, (target) => fetchFileInfo(target, paneId), (target, download) => fileUrl(target, paneId, download),
), [filePath, paneId, fetchFileInfo, fileUrl]);
const body = (() => {
if (directory !== null) return <DirectoryBrowser key={directory} start={directory} onOpenFile={onOpen ?? setPath} />;
if (error !== null) return <p className="file-viewer-note" role="alert">{error}</p>;
Expand All @@ -107,7 +112,7 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv
switch (info.kind) {
case "image":
return info.size > MAX_INLINE_IMAGE_BYTES
? <p className="file-viewer-note">This image is {formatBytes(info.size)}; download it to view.</p>
? <p className="file-viewer-note">{t("This image is {size}; download it to view.", { size: formatBytes(info.size) })}</p>
: <img className="file-viewer-media" src={url} alt={info.name} />;
case "video":
return <video className="file-viewer-media" src={url} controls playsInline preload="metadata" />;
Expand All @@ -117,7 +122,9 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv
return <iframe className="file-viewer-pdf" src={url} title={info.name} />;
case "text":
return text === null ? <p className="file-viewer-note">{t("Opening…")}</p> : <>
<pre className="file-viewer-text">{text}</pre>
{/\.(?:md|markdown)$/i.test(info.name)
? <MarkdownImageContext.Provider value={imagePreview}><Markdown className="file-viewer-markdown">{text}</Markdown></MarkdownImageContext.Provider>
: <pre className="file-viewer-text">{text}</pre>}
{textTruncated && (textTotal ?? 0) <= TEXT_PREVIEW_BYTES ? <p className="file-viewer-note">{t("Only part of this file is shown. Download it to read the rest.")}</p>
: (textTotal ?? info.size) > TEXT_PREVIEW_BYTES && <p className="file-viewer-note">{t("Showing the first {shown} of {total}.", { shown: formatBytes(TEXT_PREVIEW_BYTES), total: formatBytes(textTotal ?? info.size) })}</p>}
</>;
Expand Down
90 changes: 87 additions & 3 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { createContext, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, Copy, Maximize2 } from "lucide-react";

import { foldCode, mathNestsTooDeep, parseMarkdown, type InlineNode, type ListBlock, type MarkdownBlock } from "../lib/markdown.ts";
import { codeIsFilePath, OpenFileContext, splitFilePaths } from "../lib/filePaths.ts";
import { fileUriPath } from "../lib/terminalFileLinks.ts";
import { CHAT_HIGHLIGHT_LIMIT, languageForFence } from "../lib/highlight.ts";
import { HighlightedCode } from "./HighlightedCode.tsx";
import { useT } from "../lib/i18n.ts";
import { type MarkdownImagePreview } from "../lib/markdownImages.ts";
import { formatBytes } from "../lib/bridgeProgress.ts";
import { MermaidLightbox } from "./MermaidLightbox.tsx";
import { copyText } from "../lib/clipboard.ts";

type Katex = typeof import("katex").default;
Expand Down Expand Up @@ -50,6 +53,79 @@ function MathExpression({ value, displayMode = false }: { value: string; display
}
}

type MermaidRender = typeof import("../lib/mermaid.ts").renderMermaid;
let mermaidLoad: Promise<MermaidRender> | null = null;

/** Mermaid, fetched once with the first diagram (lib/mermaid.ts); offline before that, a diagram stays a code block. */
function loadMermaid(): Promise<MermaidRender> {
mermaidLoad ??= import("../lib/mermaid.ts").then((module) => module.renderMermaid);
return mermaidLoad;
}

/** Whether the page is drawn dark (`data-theme` on <html>, which Settings keeps current). */
function useDarkTheme(): boolean {
const [dark, setDark] = useState(() => document.documentElement.dataset.theme !== "light");
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => setDark(root.dataset.theme !== "light"));
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}, []);
return dark;
}

/** A ```mermaid fence drawn as a diagram. Until it parses (a reply still streaming, a syntax error) it shows as the code block it is. */
function MermaidDiagram({ value }: { value: string }) {
const t = useT();
const [svg, setSvg] = useState<string | null>(null);
const [zoomed, setZoomed] = useState(false);
const dark = useDarkTheme();
useEffect(() => {
let live = true;
// the parse runs after a pause, so a streaming block is not re-parsed on every poll
const timer = window.setTimeout(() => {
loadMermaid().then((render) => render(value, dark)).then(
(markup) => { if (live) setSvg(markup); },
() => { if (live) setSvg(null); },
);
}, 300);
return () => { live = false; window.clearTimeout(timer); };
}, [value, dark]);
if (svg === null) return <CodeBlock language="mermaid" value={value} />;
return (
<div className="markdown-mermaid-frame">
<div className="markdown-mermaid" onClick={() => setZoomed(true)} dangerouslySetInnerHTML={{ __html: svg }} />
<button type="button" className="icon-button markdown-mermaid-zoom" onClick={() => setZoomed(true)} aria-label={t("Enlarge diagram")} title={t("Enlarge diagram")}>
<Maximize2 aria-hidden="true" />
</button>
{zoomed && <MermaidLightbox svg={svg} onClose={() => setZoomed(false)} />}
</div>
);
}

/** Stats an image before it can load; null context keeps chat images as links. */
type MarkdownImageResolver = (src: string) => Promise<MarkdownImagePreview | null>;
export const MarkdownImageContext = createContext<MarkdownImageResolver | null>(null);

function MarkdownImage({ src, alt, fallback, resolve, interactive }: { src: string; alt: string; fallback: ReactNode; resolve: MarkdownImageResolver; interactive: boolean }) {
const t = useT();
const [loaded, setLoaded] = useState<{ src: string; resolve: MarkdownImageResolver; preview: MarkdownImagePreview | null } | null>(null);
useEffect(() => {
let live = true;
resolve(src).then(
(preview) => { if (live) setLoaded({ src, resolve, preview }); },
() => { if (live) setLoaded({ src, resolve, preview: null }); },
);
return () => { live = false; };
}, [src, resolve]);
const preview = loaded?.src === src && loaded.resolve === resolve ? loaded.preview : undefined;
if (preview === undefined) return <span>{alt}</span>;
if (preview === null) return <>{fallback}</>;
if (preview.kind === "inline") return <img className="markdown-image" src={preview.url} alt={alt} loading="lazy" />;
const message = t("This image is {size}; download it to view.", { size: formatBytes(preview.size) });
return interactive ? <a href={preview.downloadUrl} download>{message}</a> : <span>{message}</span>;
}

/** A file path the viewer opens: a button that reads as the text or code it replaced. */
function FilePath({ path, code, open }: { path: string; code: boolean; open: (path: string) => void }) {
const t = useT();
Expand All @@ -62,6 +138,7 @@ function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactiv
const context = useContext(OpenFileContext);
const open = interactive ? context : null;
const t = useT();
const imageResolver = useContext(MarkdownImageContext);
return <>{nodes.map((node, index) => {
const key = `${node.type}-${index}`;
switch (node.type) {
Expand All @@ -76,6 +153,11 @@ function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactiv
return open !== null && codeIsFilePath(node.value) ? <FilePath key={key} path={node.value} code open={open} /> : <code key={key}>{node.value}</code>;
}
case "math": return <MathExpression key={key} value={node.value} />;
case "image": {
return imageResolver === null
? <Inline key={key} nodes={node.fallback} interactive={interactive} />
: <MarkdownImage key={key} src={node.src} alt={node.alt} resolve={imageResolver} interactive={interactive} fallback={<Inline nodes={node.fallback} interactive={interactive} />} />;
}
case "strong": return <strong key={key}><Inline nodes={node.children} interactive={interactive} /></strong>;
case "em": return <em key={key}><Inline nodes={node.children} interactive={interactive} /></em>;
case "del": return <del key={key}><Inline nodes={node.children} interactive={interactive} /></del>;
Expand Down Expand Up @@ -173,7 +255,9 @@ function Blocks({ blocks }: { blocks: MarkdownBlock[] }) {
return <p key={key}>{block.lines.map((line, lineIndex) => <span key={lineIndex}><Inline nodes={line} />{lineIndex < block.lines.length - 1 && <br />}</span>)}</p>;
case "list": return <List key={key} block={block} />;
case "blockquote": return <blockquote key={key}><Blocks blocks={block.blocks} /></blockquote>;
case "code": return <CodeBlock key={key} language={block.language} value={block.value} />;
case "code": return block.language === "mermaid"
? <MermaidDiagram key={key} value={block.value} />
: <CodeBlock key={key} language={block.language} value={block.value} />;
case "math": return <MathExpression key={key} value={block.value} displayMode />;
case "hr": return <hr key={key} />;
case "table": return (
Expand Down
63 changes: 63 additions & 0 deletions src/components/MermaidLightbox.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* above the file viewer (modal + 1), which opens diagrams of its own markdown */
.mermaid-lightbox-scrim {
z-index: calc(var(--z-modal) + 2);
padding: 0;
background: var(--bg);
}

.mermaid-lightbox {
position: relative;
width: 100%;
height: 100%;
background: var(--bg);
overflow: hidden;
}

.mermaid-lightbox-bar {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 1;
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1);
background: var(--bg-elevated);
border: var(--hairline) solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop);
}

.mermaid-lightbox-zoom {
min-width: 4ch;
text-align: center;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-xs);
font-variant-numeric: tabular-nums;
}

/* the stage never scrolls: the diagram moves inside it, and the page behind stays put */
.mermaid-lightbox-stage {
position: absolute;
inset: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
}

.mermaid-lightbox-stage.is-dragging { cursor: grabbing; }

/* the svg fills the canvas and keeps its aspect ratio, which is the fit */
.mermaid-lightbox-canvas {
position: absolute;
inset: var(--space-8) var(--space-4) var(--space-4);
transform-origin: 0 0;
}

.mermaid-lightbox-canvas svg {
display: block;
width: 100%;
height: 100%;
}
Loading
Loading