Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 38ea78b489 chore: v2.8.33 — an HTML file opens as the page (Files panel, Chat preview, File Explorer) 2026-10-28 16:46:21 +07:00
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00
14 changed files with 638 additions and 30 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.32 (2026-10-28) — a Claude plan is the one Anthropic's profile endpoint reports now._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.8.33 (2026-10-28) — an HTML file opens as the page (Files panel, Chat preview, File Explorer)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,7 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes. - **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
+98
View File
@@ -0,0 +1,98 @@
/**
* @file HtmlFrame.tsx
* @description An HTML file rendered as a page, the one way every file view
* shows one (a Chat file preview, the Changes panel's Preview, the File
* Explorer's viewer): its relative CSS, scripts and images pulled in through
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
* file itself through the same `fetchFile`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */
const HTML_MAX = 10 * 1024 * 1024;
export function HtmlFrame({
path,
html,
fetchFile,
title,
className = "",
testId = "html-frame",
}: {
/** The file's path relative to the place `fetchFile` reads from. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
/** Reads the page's relative files; none = the page as it is. */
fetchFile?: FetchFile | null;
title: string;
className?: string;
testId?: string;
}) {
const [doc, setDoc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
// The page shown stays until its successor is ready (a version step, a
// re-read): no flash of the spinner between two renders of one file.
setFailed(false);
(async () => {
let text = html;
if (text === undefined) {
if (!fetchFile) throw new Error("nothing to read the page with");
const b = await fetchFile(path);
if (b.size > HTML_MAX) throw new Error("too big");
text = await blobText(b);
}
const page = text;
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
})().then(
(d) => live && setDoc(d),
() => live && setFailed(true)
);
return () => {
live = false;
};
}, [html, path, fetchFile]);
if (failed) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
if (doc === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
return (
<iframe
data-testid={testId}
title={title}
srcDoc={artifactDocument("html", doc, false) ?? ""}
sandbox={frameSandbox("html")}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
/>
);
}
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
export function fetchVia(url: (relPath: string) => string): FetchFile {
return async (rel) => {
const r = await fetch(url(rel), { credentials: "same-origin" });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
};
}
+82 -2
View File
@@ -17,7 +17,8 @@
* (the clipboard outlives the dialog); upload files or whole folders (picker * (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch; * or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as * drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both / * itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
* Skip. Deleting forever asks first when it empties the Trash or touches * Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its * another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge * item count); each folder is THAT user's Trash, to restore from or purge
@@ -90,6 +91,7 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
@@ -166,6 +168,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const errCode = (e: unknown) => (e as { code?: string })?.code; const errCode = (e: unknown) => (e as { code?: string })?.code;
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir"; const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase(); const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]); const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
const CODE = new Set([ const CODE = new Set([
@@ -498,8 +501,11 @@ export function FileBrowser({
}; };
// ── Actions ───────────────────────────────────────────────────────────── // ── Actions ─────────────────────────────────────────────────────────────
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
const [viewing, setViewing] = useState<FsEntry | null>(null);
const openEntry = (e: FsEntry) => { const openEntry = (e: FsEntry) => {
if (isFolder(e)) go(e.path); if (isFolder(e)) go(e.path);
else if (isHtml(e)) setViewing(e);
else download([e]); else download([e]);
}; };
const goUp = () => { const goUp = () => {
@@ -970,6 +976,14 @@ export function FileBrowser({
onClick={() => setMenu(null)} onClick={() => setMenu(null)}
data-testid="file-explorer" data-testid="file-explorer"
> >
{viewing && (
<HtmlViewer
entry={viewing}
place={target}
onDownload={() => download([viewing])}
onClose={() => setViewing(null)}
/>
)}
{cloneParent && ( {cloneParent && (
<CloneHereDialog <CloneHereDialog
open={cloning} open={cloning}
@@ -1656,13 +1670,18 @@ export function FileBrowser({
{menuItem ? ( {menuItem ? (
<> <>
<MenuItem onSelect={close(() => openEntry(menuItem))}> <MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")} {isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem> </MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && ( {(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}> <MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")} {t("fx.downloadZip")}
</MenuItem> </MenuItem>
)} )}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>
{t("fx.download")}
</MenuItem>
)}
<MenuSeparator /> <MenuSeparator />
{!ro && ( {!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}> <MenuItem onSelect={close(() => putClip(menuTargets, true))}>
@@ -1916,6 +1935,67 @@ export function FileExplorer({
); );
} }
/** An HTML file shown as the page, over the explorer: its relative CSS,
* scripts and images read through the same download route (`HtmlFrame`).
* Keys and clicks stop here — through the portal they would otherwise reach
* the explorer's own shortcuts (Delete would trash the selection). */
function HtmlViewer({
entry,
place,
onDownload,
onClose,
}: {
entry: FsEntry;
place: FsPlace;
onDownload: () => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={entry.name}
data-testid="fx-html-viewer"
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{entry.path}
</span>
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
</div>
</div>,
document.body
);
}
function HeadButton({ function HeadButton({
label, label,
onClick, onClick,
@@ -13,7 +13,9 @@
* side, each block with the diff's Undo / Keep while nothing * side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file * is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a * shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an * whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS,
* scripts and images read like downloads), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the * browse — quick-open by path, or the repo's tracked file tree (the
@@ -77,7 +79,16 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronDown, ChevronDown,
@@ -127,6 +138,7 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer"; import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach"; import { dragProjectPath } from "../../lib/promptAttach";
@@ -185,6 +197,9 @@ function Delta({
); );
} }
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */ /** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited. // CodeMirror is loaded only once a file is edited.
@@ -874,10 +889,11 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified"); const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file, each // A whole markdown or HTML file shown rendered instead of as source; per
// one opening as Settings → Appearance says (`lib/markdownView.ts`). // file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page always as the page.
const [mdPreview, setMdPreview] = useState(readMarkdownPreview); const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]); useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null); const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones) // A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads // has no changes, review or history — only its files, which Files reads
@@ -2042,8 +2058,16 @@ export function LaneChangesPanel({
}); });
const dirty = draft !== null && draft !== text?.lines.join("\n"); const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file. // Rendered only where the toggle is offered: a whole markdown or HTML file.
const mdShown = mdPreview && whole && isMarkdown(openFile); const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
dirtyRef.current = dirty; dirtyRef.current = dirty;
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange); const dirtyChangeRef = useRef(onDirtyChange);
@@ -2969,7 +2993,7 @@ export function LaneChangesPanel({
{t("changes.annotate")} {t("changes.annotate")}
</button> </button>
)} )}
{whole && text && !dirty && isMarkdown(openFile) && ( {whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
<button <button
type="button" type="button"
data-testid="lane-file-preview" data-testid="lane-file-preview"
@@ -3113,7 +3137,17 @@ export function LaneChangesPanel({
</Suspense> </Suspense>
) )
) : whole ? ( ) : whole ? (
text && mdShown ? ( text && mdShown && isHtml(openFile) ? (
<HtmlFrame
key={openFile}
path={text.path}
// A cut-off page is read whole through the download.
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
fetchFile={htmlFetch}
title={text.path}
className="rounded border border-border-light"
/>
) : text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4"> <div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} /> <MarkdownPreview text={text.lines.join("\n")} />
</div> </div>
@@ -9,7 +9,8 @@
* purge there, sending the item's store) and * purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty; nests a * shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row * user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home). * when the viewer may not open that home); opens an HTML file as the page
* instead of downloading it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
expect(api.files.list).toHaveBeenLastCalledWith(1, "src"); expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
}); });
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
it("makes a folder inline", async () => { it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" }); vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup(); const user = userEvent.setup();
@@ -544,6 +544,34 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("opens a whole HTML file as the page, its source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
});
it("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
+49 -9
View File
@@ -39,6 +39,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach"; import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView"; import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock"; import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -85,6 +86,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) => export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
/** A file inside the pane's folder: the git target that reads it and its path there. */
function workspaceFile(path: string, scope: PreviewScope) {
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
return { target, rel: file.slice(base.length + 1) };
}
type Content = type Content =
| { kind: "loading" } | { kind: "loading" }
| { kind: "image"; src: string } | { kind: "image"; src: string }
@@ -121,8 +134,8 @@ function useContent(path: string, scope: PreviewScope): Content {
}; };
} }
const file = slashed(path); const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, ""); const inside = workspaceFile(path, scope);
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) { if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read. // Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) { if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show( show(
@@ -147,11 +160,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false; live = false;
}; };
} }
const rel = file.slice(base.length + 1); const { target, rel } = inside;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) { if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return; return;
@@ -235,9 +244,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview"; return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
} }
function Body({ path, content, view }: { path: string; content: Content; view: View }) { function Body({
path,
content,
view,
scope,
}: {
path: string;
content: Content;
view: View;
scope: PreviewScope;
}) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const kind = artifactKind(path); const kind = artifactKind(path);
// A page in the workspace brings its relative CSS, scripts and images along.
const inside = kind === "html" ? workspaceFile(path, scope) : null;
const insideTarget = inside ? JSON.stringify(inside.target) : null;
const page = useMemo(() => {
if (!inside) return null;
const { target, rel } = inside;
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview. // An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null); const [broken, setBroken] = useState<string | null>(null);
const shown: Content = const shown: Content =
@@ -290,6 +318,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
); );
} }
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />; if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
if (page && !shown.truncated) {
return (
<HtmlFrame
key={path}
path={page.rel}
html={shown.text}
fetchFile={page.fetchFile}
title={baseName(path)}
testId="artifact-frame"
/>
);
}
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />; return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
} }
@@ -503,7 +543,7 @@ export function FilePreview({
</button> </button>
</div> </div>
</header> </header>
<Body path={path} content={content} view={view} /> <Body path={path} content={content} view={view} scope={scope} />
</section> </section>
); );
// Full screen: over the whole page, where a pane's edges cannot clip it. // Full screen: over the whole page, where a pane's edges cannot clip it.
@@ -242,7 +242,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc") ((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two"); ).toContain("two");
fireEvent.click(versions.querySelector("button")!); fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>"); await vi.waitFor(() =>
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
);
cleanup(); cleanup();
}); });
@@ -0,0 +1,53 @@
/**
* @file htmlInline.test.ts
* @description An HTML preview's relative CSS, scripts and images are inlined
* from the place's own files, never from above its root or off a URL.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { inlineHtml, resolveRef } from "../htmlInline";
const files: Record<string, string> = {
"site/css/app.css": "body{background:url(../img/bg.png)}",
"site/js/app.js": "window.ok=1",
"site/img/logo.png": "PNG",
"site/img/bg.png": "BG",
};
const fetchFile = vi.fn(async (p: string) => {
if (!(p in files)) throw new Error("404");
return new Blob([files[p]!]);
});
describe("resolveRef", () => {
it("resolves against the page's folder, from the root on a leading slash", () => {
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
expect(resolveRef("site", "/top.css")).toBe("top.css");
});
it("skips URLs, anchors and anything above the root", () => {
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
expect(resolveRef("site", r)).toBeNull();
});
});
describe("inlineHtml", () => {
it("inlines stylesheets (with their url()s), scripts and images", async () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
const out = await inlineHtml(html, "site/index.html", fetchFile);
expect(out.startsWith("<!doctype html>")).toBe(true);
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
expect(out).not.toContain("srcset");
expect(out).toContain('src="https://cdn.example/x.js"');
expect(out).toContain('src="missing.png"');
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
});
it("returns the page untouched when nothing is local", async () => {
const html = "<p>hi <a href='https://x'>x</a></p>";
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @file htmlInline.ts
* @description An HTML file made whole for its sandboxed preview. The page
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
* nothing from the dashboard — and the dashboard's login cookie is
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
* the `url(...)`s inside its CSS through a read route that already guards
* them (the caller's `fetchFile`: a workspace's or a home's download), and
* writes them into the page as `<style>` and `data:` URLs before it renders.
* Only paths inside the page's own place are followed (`..` above its root is
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
* all, so a big site never stalls the viewer. A reference that cannot be read
* is left as it was. `fetch()`/XHR from the page's own script, ES module
* imports and links to other pages still do not work.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Reads one file by its path relative to the place's root; throws when it cannot. */
export type FetchFile = (relPath: string) => Promise<Blob>;
const MAX_ASSETS = 60;
const MAX_BYTES = 30 * 1024 * 1024;
const MIME: Record<string, string> = {
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
};
const mimeOf = (p: string) =>
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
/** The folder part of a relative path ("" at the root). */
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
/**
* A page's reference as a path from the place's root, or null when it is not
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
* A leading `/` counts from the root, as a site served from it would.
*/
export function resolveRef(dir: string, ref: string): string | null {
const r = ref.trim();
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
let p = r.replace(/[?#].*$/, "");
try {
p = decodeURIComponent(p);
} catch {
// A stray % is a literal one.
}
const out: string[] = [];
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
if (!seg || seg === ".") continue;
if (seg === "..") {
if (!out.length) return null;
out.pop();
} else out.push(seg);
}
return out.length ? out.join("/") : null;
}
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
export function blobText(b: Blob): Promise<string> {
if (typeof b.text === "function") return b.text();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(String(r.result));
r.onerror = () => fail(r.error);
r.readAsText(b);
});
}
function blobBytes(b: Blob): Promise<ArrayBuffer> {
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(r.result as ArrayBuffer);
r.onerror = () => fail(r.error);
r.readAsArrayBuffer(b);
});
}
function base64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
/** Inline the relative resources of `html` (the file at `htmlPath`). */
export async function inlineHtml(
html: string,
htmlPath: string,
fetchFile: FetchFile
): Promise<string> {
const doc = new DOMParser().parseFromString(html, "text/html");
const cache = new Map<string, Promise<Blob | null>>();
let count = 0;
let bytes = 0;
let changed = false;
const read = (rel: string): Promise<Blob | null> => {
let p = cache.get(rel);
if (!p) {
if (count >= MAX_ASSETS) return Promise.resolve(null);
count++;
p = fetchFile(rel)
.then((b) => {
if (bytes + b.size > MAX_BYTES) return null;
bytes += b.size;
return b;
})
.catch(() => null);
cache.set(rel, p);
}
return p;
};
const dataUrl = async (rel: string): Promise<string | null> => {
const b = await read(rel);
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
};
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
const inlineCss = async (css: string, dir: string): Promise<string> => {
const refs = new Map<string, string | null>();
for (const m of css.matchAll(URL_RE)) {
const rel = resolveRef(dir, m[2]!);
if (rel && !refs.has(rel)) refs.set(rel, null);
}
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
return css.replace(URL_RE, (all, _q, ref: string) => {
const rel = resolveRef(dir, ref);
const url = rel ? refs.get(rel) : null;
return url ? `url("${url}")` : all;
});
};
const dir = dirOf(htmlPath);
const jobs: Promise<void>[] = [];
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
const rel = resolveRef(dir, link.getAttribute("href")!);
if (!rel) continue;
jobs.push(
(async () => {
const b = await read(rel);
if (!b) return;
const style = doc.createElement("style");
const media = link.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
link.replaceWith(style);
changed = true;
})()
);
}
for (const style of doc.querySelectorAll("style")) {
const css = style.textContent || "";
if (!/url\(/i.test(css)) continue;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
style.textContent = out;
changed = true;
}
})
);
}
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
const css = el.getAttribute("style")!;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
el.setAttribute("style", out);
changed = true;
}
})
);
}
const attrs: [string, string][] = [
["script[src]", "src"],
["img[src]", "src"],
["source[src]", "src"],
["video[src]", "src"],
["audio[src]", "src"],
["video[poster]", "poster"],
["input[type='image' i][src]", "src"],
["link[rel~='icon' i][href]", "href"],
];
for (const [sel, attr] of attrs) {
for (const el of doc.querySelectorAll(sel)) {
const rel = resolveRef(dir, el.getAttribute(attr)!);
if (!rel) continue;
jobs.push(
dataUrl(rel).then((url) => {
if (!url) return;
el.setAttribute(attr, url);
// A srcset beside it would win over the inlined src and still miss.
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
changed = true;
})
);
}
}
await Promise.all(jobs);
if (!changed) return html;
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
return doctype + doc.documentElement.outerHTML;
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.32 (rebuild client to match) UI build v2.8.33 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+12 -2
View File
@@ -677,8 +677,18 @@ reads the workspace's git surface in five modes:
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
least one line tall (`MIN_ROW`), so an empty filler row keeps the two least one line tall (`MIN_ROW`), so an empty filler row keeps the two
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
shown as the picture (`/git/raw`), and a conflicted file opens in the shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens
conflict view. **Cancel** reverts unsaved edits. **Save** (or as the page itself — **Raw** shows its source in the editor, **Preview**
brings the page back — and a conflicted file opens in the
conflict view. The page (here, in a Chat file preview, and on a
double-click in the File Explorer, whose right-click menu keeps
**Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`:
`<iframe srcdoc>`, scripts but never `allow-same-origin`); its relative
stylesheets (and their `url()`s), scripts, images, media and icons are
read by the dashboard through the same download route and written into
it as `<style>`/`data:` URLs (`client/src/lib/htmlInline.ts`: never above
the folder's root, at most 60 files / 30 MB a page). The page's own
`fetch()`, ES module imports and links to other pages do not resolve. **Cancel** reverts unsaved edits. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF `409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.33",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.33",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },