feat(plugins): add bundled relays workspace panel plugin

This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 23:14:21 +02:00
parent d19fca4090
commit 87c09982e9
13 changed files with 1591 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
+19
View File
@@ -280,6 +280,25 @@ Task fields:
Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories.
### Relays
**Plugin id:** `relays`
**What it does:** adds a read-only **Relays** workspace tab for browsing the workspace's relays, plus an **Open Workspace Relays** action for the selected workspace that opens the same tab.
A relay is a directory of markdown notes under `.pi-web/relays/<name>/` in the workspace root — the convention used by the Relay method for chaining agent sessions. The tab lists each relay's documents with `status.md`, `charter.md`, and `log.md` first (in that order), followed by any other files alphabetically, and opens `status.md` by default. Markdown documents render as sanitized HTML; other files render as preformatted text, and binary files have no preview. Truncated documents show a notice, and **Refresh** re-scans the workspace and reloads the open document.
With several relays, a picker pre-selects the most recently modified one; a single relay opens directly. A workspace without `.pi-web/relays/` shows an empty state explaining the convention. The tab never creates, edits, or deletes relay files.
Relays is enabled by default. To hide it, disable `relays` in **Settings → PI WEB plugins** or set:
```json
{
"plugins": {
"relays": { "enabled": false }
}
}
```
## Discovery and packaging
PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument";
describe("isMarkdownDocumentPath", () => {
it("matches .md documents case-insensitively", () => {
expect(isMarkdownDocumentPath(".pi-web/relays/r/status.md")).toBe(true);
expect(isMarkdownDocumentPath("NOTES.MD")).toBe(true);
expect(isMarkdownDocumentPath(".pi-web/relays/r/data.json")).toBe(false);
expect(isMarkdownDocumentPath(".pi-web/relays/r/markdown.txt")).toBe(false);
});
});
describe("renderRelayDocumentHtml", () => {
it("renders GFM markdown: headings, emphasis, lists, and code fences", () => {
const fragment = fragmentOf(renderRelayDocumentHtml([
"# Status",
"",
"All **good**.",
"",
"- one",
"- two",
"",
"```sh",
"echo hi",
"```",
].join("\n")));
expect(fragment.querySelector("h1")?.textContent).toBe("Status");
expect(fragment.querySelector("strong")?.textContent).toBe("good");
expect(fragment.querySelectorAll("li")).toHaveLength(2);
expect(fragment.querySelector("pre code")?.textContent).toContain("echo hi");
});
it("renders soft line breaks as <br> so plain-wrapped relay docs stay readable", () => {
const fragment = fragmentOf(renderRelayDocumentHtml("one\ntwo"));
expect(fragment.querySelector("p br")).not.toBeNull();
});
it("escapes raw HTML instead of embedding it", () => {
const fragment = fragmentOf(renderRelayDocumentHtml("before\n\n<script>alert('xss')</script>\n\n<em>after</em>"));
expect(fragment.querySelector("script")).toBeNull();
expect(fragment.querySelector("em")).toBeNull();
expect(fragment.textContent).toContain("<script>alert('xss')</script>");
});
it("strips javascript: URLs from links and images while keeping safe protocols", () => {
const fragment = fragmentOf(renderRelayDocumentHtml([
"[bad](javascript:alert('xss'))",
"",
"[web](https://example.com/docs)",
"",
"[mail](mailto:[email protected])",
"",
"[anchor](#details)",
"",
"![pic](javascript:alert('xss'))",
].join("\n")));
const links = [...fragment.querySelectorAll("a")];
expect(links.find((link) => link.textContent === "bad")?.hasAttribute("href")).toBe(false);
expect(links.find((link) => link.textContent === "web")?.getAttribute("href")).toBe("https://example.com/docs");
expect(links.find((link) => link.textContent === "mail")?.getAttribute("href")).toBe("mailto:[email protected]");
expect(links.find((link) => link.textContent === "anchor")?.getAttribute("href")).toBe("#details");
expect(fragment.querySelector("img")?.hasAttribute("src")).toBe(false);
});
it("forces rendered links to open in a new tab without opener access", () => {
const fragment = fragmentOf(renderRelayDocumentHtml("[docs](https://example.com)"));
const link = fragment.querySelector("a");
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
});
it("wraps tables in a labeled scroll region", () => {
const fragment = fragmentOf(renderRelayDocumentHtml("| a | b |\n| - | - |\n| 1 | 2 |"));
const wrapper = fragment.querySelector(".table-scroll");
expect(wrapper?.getAttribute("role")).toBe("region");
expect(wrapper?.getAttribute("aria-label")).toBe("Table");
expect(wrapper?.querySelector("table")).not.toBeNull();
expect(wrapper?.querySelectorAll("td")).toHaveLength(2);
});
});
/** Parse rendered HTML back into a fragment for assertions. */
function fragmentOf(html: string): DocumentFragment {
const template = document.createElement("template");
template.innerHTML = html;
return template.content;
}
+82
View File
@@ -0,0 +1,82 @@
import { marked } from "./vendor/marked.esm.js";
// Raw HTML inside relay documents is escaped before sanitizing, so the
// sanitizer only ever sees marked-generated markup. This mirrors the safety
// rules of the app's renderer in src/client/src/formatting/markdown.ts; the
// plugin cannot import that module, so the rules are duplicated here.
const renderer = new marked.Renderer();
renderer.html = ({ text }) => escapeHtml(text);
const MAX_MARKDOWN_CACHE_ENTRIES = 300;
const markdownHtmlCache = new Map<string, string>();
/** Relay documents ending in .md render as markdown; everything else stays preformatted text. */
export function isMarkdownDocumentPath(path: string): boolean {
return path.toLowerCase().endsWith(".md");
}
/** Render one relay markdown document into sanitized HTML safe to interpolate into innerHTML. */
export function renderRelayDocumentHtml(source: string): string {
const cached = markdownHtmlCache.get(source);
if (cached !== undefined) return cached;
const html = marked.parse(source, { async: false, breaks: true, gfm: true, renderer });
const safeHtml = sanitizeHtml(html);
markdownHtmlCache.set(source, safeHtml);
if (markdownHtmlCache.size > MAX_MARKDOWN_CACHE_ENTRIES) {
const oldest = markdownHtmlCache.keys().next().value;
if (oldest !== undefined) markdownHtmlCache.delete(oldest);
}
return safeHtml;
}
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
const TABLE_SCROLL_CLASS = "table-scroll";
function sanitizeHtml(html: string): string {
const template = document.createElement("template");
template.innerHTML = html;
template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); });
template.content.querySelectorAll("*").forEach((element) => {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on")) element.removeAttribute(attribute.name);
if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name);
}
if (element.tagName === "A") {
element.setAttribute("target", "_blank");
element.setAttribute("rel", "noreferrer noopener");
}
});
wrapTablesInScrollRegions(template.content);
return template.innerHTML;
}
// Tables keep their natural width and scroll horizontally instead of being
// squeezed into the panel, which is unreadable on narrow screens.
function wrapTablesInScrollRegions(root: DocumentFragment): void {
root.querySelectorAll("table").forEach((table) => {
if (table.parentElement?.classList.contains(TABLE_SCROLL_CLASS) === true) return;
const wrapper = document.createElement("div");
wrapper.className = TABLE_SCROLL_CLASS;
wrapper.setAttribute("role", "region");
wrapper.setAttribute("aria-label", "Table");
wrapper.setAttribute("tabindex", "0");
table.before(wrapper);
wrapper.append(table);
});
}
function isSafeUrl(url: string): boolean {
if (url.startsWith("#") || url.startsWith("/")) return true;
try {
return ["http:", "https:", "mailto:"].includes(new URL(url).protocol);
} catch {
return false;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@pi-web/relays-plugin",
"private": true,
"piWeb": {
"plugins": [
{ "id": "relays", "module": "pi-web-plugin.js" }
]
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { RELAYS_ROOT } from "./relayDiscovery.js";
import { defineRelaysPanelElement } from "./relaysPanelElement.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "Relays",
activate: ({ pluginId, html, svg }) => {
defineRelaysPanelElement();
return {
contributions: {
actions: [
{
id: "workspace.open-relays",
title: "Open Workspace Relays",
description: `Open the workspace Relays tab. Relays live in ${RELAYS_ROOT}.`,
group: "Workspace",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
if (context.state.selectedWorkspace === undefined) return;
context.selectWorkspaceTool(`${pluginId}:workspace.relays`);
},
},
],
workspacePanels: [
{
id: "workspace.relays",
title: "Relays",
icon: svg`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 17H7A5 5 0 0 1 7 7h2"></path>
<path d="M15 7h2a5 5 0 1 1 0 10h-2"></path>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
`,
order: 50,
render: (context) => html`<pi-web-relays-panel .context=${context}></pi-web-relays-panel>`,
},
],
},
};
},
};
export default plugin;
@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from "vitest";
import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api";
import {
defaultRelayDocument,
listRelayDocuments,
listWorkspaceRelays,
orderRelayDocuments,
readRelayDocument,
RELAYS_ROOT,
sortRelaysByRecency,
type RelayDiscoveryFiles,
type RelayDocumentEntry,
type RelaySummary,
} from "./relayDiscovery";
describe("listWorkspaceRelays", () => {
it("lists relay directories through the workspace files helper, most recently modified first", async () => {
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>(() => Promise.resolve(tree([
directoryEntry("older", "2026-01-01T00:00:00.000Z"),
fileEntry("stray-file.md", "2026-03-01T00:00:00.000Z"),
directoryEntry("newer", "2026-02-01T00:00:00.000Z"),
symlinkEntry("linked", "2026-04-01T00:00:00.000Z"),
])));
const files = filesWith({ listFiles });
const result = await listWorkspaceRelays(files);
expect(listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
expect(result).toEqual({
kind: "loaded",
relays: [
{ name: "newer", path: `${RELAYS_ROOT}/newer`, modifiedAt: "2026-02-01T00:00:00.000Z" },
{ name: "older", path: `${RELAYS_ROOT}/older`, modifiedAt: "2026-01-01T00:00:00.000Z" },
],
});
});
it("reports an empty relays directory as zero relays", async () => {
const files = filesWith({ listFiles: () => Promise.resolve(tree([])) });
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "loaded", relays: [] });
});
it("treats a missing relays directory as missing rather than a failure", async () => {
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) });
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" });
});
it("treats a relays root that is not a directory as missing rather than a failure", async () => {
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path is not a directory")) });
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "missing" });
});
it("surfaces other listing failures as unavailable with the error detail", async () => {
const files = filesWith({ listFiles: () => Promise.reject(new Error("connection lost")) });
await expect(listWorkspaceRelays(files)).resolves.toEqual({ kind: "unavailable", detail: "connection lost" });
});
});
describe("sortRelaysByRecency", () => {
it("sorts undated and invalid-dated relays after dated ones, alphabetically", () => {
const relays: RelaySummary[] = [
{ name: "zulu", path: "zulu" },
{ name: "middle", path: "middle", modifiedAt: "2026-02-01T00:00:00.000Z" },
{ name: "alpha", path: "alpha" },
{ name: "broken", path: "broken", modifiedAt: "not-a-date" },
{ name: "newest", path: "newest", modifiedAt: "2026-03-01T00:00:00.000Z" },
];
expect(sortRelaysByRecency(relays).map((relay) => relay.name)).toEqual(["newest", "middle", "alpha", "broken", "zulu"]);
});
it("does not mutate the input array", () => {
const relays: RelaySummary[] = [
{ name: "b", path: "b", modifiedAt: "2026-01-01T00:00:00.000Z" },
{ name: "a", path: "a", modifiedAt: "2026-02-01T00:00:00.000Z" },
];
sortRelaysByRecency(relays);
expect(relays.map((relay) => relay.name)).toEqual(["b", "a"]);
});
});
describe("listRelayDocuments", () => {
it("lists relay files with anchor documents first, then alphabetical", async () => {
const relayPath = `${RELAYS_ROOT}/my-relay`;
const relayFile = (name: string): FileTreeEntry => ({ name, path: `${relayPath}/${name}`, type: "file" });
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>(() => Promise.resolve(tree([
relayFile("notes.md"),
relayFile("log.md"),
directoryEntry("subdir"),
relayFile("status.md"),
relayFile("charter.md"),
relayFile("data.json"),
])));
const files = filesWith({ listFiles });
const result = await listRelayDocuments(files, relayPath);
expect(listFiles).toHaveBeenCalledWith(relayPath);
expect(result).toEqual({
kind: "loaded",
documents: [
{ name: "status.md", path: `${relayPath}/status.md`, modifiedAt: undefined },
{ name: "charter.md", path: `${relayPath}/charter.md`, modifiedAt: undefined },
{ name: "log.md", path: `${relayPath}/log.md`, modifiedAt: undefined },
{ name: "data.json", path: `${relayPath}/data.json`, modifiedAt: undefined },
{ name: "notes.md", path: `${relayPath}/notes.md`, modifiedAt: undefined },
],
});
});
it("treats a vanished relay directory as missing", async () => {
const files = filesWith({ listFiles: () => Promise.reject(new Error("Path does not exist")) });
await expect(listRelayDocuments(files, `${RELAYS_ROOT}/gone`)).resolves.toEqual({ kind: "missing" });
});
it("surfaces other listing failures as unavailable", async () => {
const files = filesWith({ listFiles: () => Promise.reject(new Error("boom")) });
await expect(listRelayDocuments(files, `${RELAYS_ROOT}/x`)).resolves.toEqual({ kind: "unavailable", detail: "boom" });
});
});
describe("orderRelayDocuments", () => {
it("puts anchor documents first in fixed order without mutating the input", () => {
const documents: RelayDocumentEntry[] = [
{ name: "zebra.md", path: "zebra.md" },
{ name: "log.md", path: "log.md" },
{ name: "status.md", path: "status.md" },
];
const ordered = orderRelayDocuments(documents);
expect(ordered.map((document) => document.name)).toEqual(["status.md", "log.md", "zebra.md"]);
expect(documents.map((document) => document.name)).toEqual(["zebra.md", "log.md", "status.md"]);
});
});
describe("defaultRelayDocument", () => {
it("picks status.md when present", () => {
const documents: RelayDocumentEntry[] = [
{ name: "charter.md", path: "charter.md" },
{ name: "status.md", path: "status.md" },
];
expect(defaultRelayDocument(documents)?.name).toBe("status.md");
});
it("falls back to the first ordered document when status.md is absent", () => {
const documents: RelayDocumentEntry[] = [
{ name: "alpha.md", path: "alpha.md" },
{ name: "charter.md", path: "charter.md" },
];
expect(defaultRelayDocument(documents)?.name).toBe("charter.md");
});
it("returns undefined for a relay without documents", () => {
expect(defaultRelayDocument([])).toBeUndefined();
});
});
describe("readRelayDocument", () => {
it("returns document content with its truncation and binary flags", async () => {
const files = filesWith({
readFile: () => Promise.resolve(fileContent({ content: "# Status", truncated: true, binary: false })),
});
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/log.md`)).resolves.toEqual({
kind: "loaded",
content: "# Status",
truncated: true,
binary: false,
});
});
it("treats a vanished document as missing", async () => {
const files = filesWith({ readFile: () => Promise.reject(new Error("Path does not exist")) });
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/gone.md`)).resolves.toEqual({ kind: "missing" });
});
it("surfaces other read failures as unavailable", async () => {
const files = filesWith({ readFile: () => Promise.reject(new Error("boom")) });
await expect(readRelayDocument(files, `${RELAYS_ROOT}/r/x.md`)).resolves.toEqual({ kind: "unavailable", detail: "boom" });
});
});
function filesWith(overrides: Partial<RelayDiscoveryFiles>): RelayDiscoveryFiles {
return {
listFiles: () => Promise.reject(new Error("listFiles not expected")),
readFile: () => Promise.reject(new Error("readFile not expected")),
...overrides,
};
}
function tree(entries: FileTreeEntry[]): FileTreeResponse {
return { path: RELAYS_ROOT, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false };
}
function directoryEntry(name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function fileEntry(name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function symlinkEntry(name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${name}`, type: "symlink", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function fileContent(overrides: Partial<FileContentResponse>): FileContentResponse {
return {
path: `${RELAYS_ROOT}/r/log.md`,
encoding: "utf8",
size: 8,
modifiedAt: "2026-01-01T00:00:00.000Z",
content: "",
truncated: false,
binary: false,
...overrides,
};
}
+140
View File
@@ -0,0 +1,140 @@
import type { FileContentResponse, FileTreeEntry, FileTreeResponse } from "@jmfederico/pi-web/plugin-api";
export const RELAYS_ROOT = ".pi-web/relays";
/** Documents that anchor a relay packet, in display order. Any other files follow alphabetically. */
export const RELAY_ANCHOR_DOCUMENTS: readonly string[] = ["status.md", "charter.md", "log.md"];
/** Structural subset of the plugin WorkspaceFiles helper this module needs. */
export interface RelayDiscoveryFiles {
listFiles(path: string): Promise<FileTreeResponse>;
readFile(path: string): Promise<FileContentResponse>;
}
export interface RelaySummary {
name: string;
path: string;
modifiedAt?: string | undefined;
}
export interface RelayDocumentEntry {
name: string;
path: string;
modifiedAt?: string | undefined;
}
export type RelaysListing =
| { kind: "loaded"; relays: RelaySummary[] }
| { kind: "missing" }
| { kind: "unavailable"; detail: string };
export type RelayDocumentsListing =
| { kind: "loaded"; documents: RelayDocumentEntry[] }
| { kind: "missing" }
| { kind: "unavailable"; detail: string };
export type RelayDocumentContent =
| { kind: "loaded"; content: string; truncated: boolean; binary: boolean }
| { kind: "missing" }
| { kind: "unavailable"; detail: string };
// The workspace file API rejects with these messages when a path is absent or
// is not a directory. For the relays root both mean "zero relays", not a failure.
const missingListingErrorMessages = new Set(["Path does not exist", "Path is not a directory"]);
/** List the workspace's relays, most recently modified first. Never rejects. */
export async function listWorkspaceRelays(files: RelayDiscoveryFiles): Promise<RelaysListing> {
let listing: FileTreeResponse;
try {
listing = await files.listFiles(RELAYS_ROOT);
} catch (error) {
return fileAccessFailure(error);
}
const relays = sortRelaysByRecency(
listing.entries
.filter((entry) => entry.type === "directory")
.map((entry) => toRelaySummary(entry)),
);
return { kind: "loaded", relays };
}
/** List one relay's documents: status.md, charter.md, log.md first, then alphabetical. Never rejects. */
export async function listRelayDocuments(files: RelayDiscoveryFiles, relayPath: string): Promise<RelayDocumentsListing> {
let listing: FileTreeResponse;
try {
listing = await files.listFiles(relayPath);
} catch (error) {
return fileAccessFailure(error);
}
const documents = orderRelayDocuments(
listing.entries
.filter((entry) => entry.type === "file")
.map((entry) => toRelayDocumentEntry(entry)),
);
return { kind: "loaded", documents };
}
/** Read one relay document. Never rejects. */
export async function readRelayDocument(files: RelayDiscoveryFiles, documentPath: string): Promise<RelayDocumentContent> {
try {
const file = await files.readFile(documentPath);
return { kind: "loaded", content: file.content, truncated: file.truncated, binary: file.binary };
} catch (error) {
return fileAccessFailure(error);
}
}
/** Newest first; relays without a usable modifiedAt sort last, alphabetically. */
export function sortRelaysByRecency(relays: RelaySummary[]): RelaySummary[] {
return [...relays].sort(compareRelaysByRecency);
}
/** Anchor documents first in fixed order, then everything else alphabetically. */
export function orderRelayDocuments(documents: RelayDocumentEntry[]): RelayDocumentEntry[] {
return [...documents].sort(compareRelayDocuments);
}
/** The document a relay opens on: status.md when present, otherwise the first ordered document. */
export function defaultRelayDocument(documents: RelayDocumentEntry[]): RelayDocumentEntry | undefined {
// Anchor ordering guarantees status.md is first whenever it exists.
return orderRelayDocuments(documents)[0];
}
function toRelaySummary(entry: FileTreeEntry): RelaySummary {
return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
}
function toRelayDocumentEntry(entry: FileTreeEntry): RelayDocumentEntry {
return { name: entry.name, path: entry.path, modifiedAt: entry.modifiedAt };
}
function compareRelaysByRecency(left: RelaySummary, right: RelaySummary): number {
const leftTime = timestampOf(left.modifiedAt);
const rightTime = timestampOf(right.modifiedAt);
if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return rightTime - leftTime;
if (leftTime !== undefined) return -1;
if (rightTime !== undefined) return 1;
return left.name.localeCompare(right.name);
}
function timestampOf(modifiedAt: string | undefined): number | undefined {
if (modifiedAt === undefined) return undefined;
const time = Date.parse(modifiedAt);
return Number.isNaN(time) ? undefined : time;
}
function compareRelayDocuments(left: RelayDocumentEntry, right: RelayDocumentEntry): number {
const anchorOrder = anchorIndexOf(left.name) - anchorIndexOf(right.name);
if (anchorOrder !== 0) return anchorOrder;
return left.name.localeCompare(right.name);
}
function anchorIndexOf(name: string): number {
const index = RELAY_ANCHOR_DOCUMENTS.indexOf(name);
return index === -1 ? RELAY_ANCHOR_DOCUMENTS.length : index;
}
function fileAccessFailure(error: unknown): { kind: "missing" } | { kind: "unavailable"; detail: string } {
if (error instanceof Error && missingListingErrorMessages.has(error.message)) return { kind: "missing" };
return { kind: "unavailable", detail: error instanceof Error ? error.message : String(error) };
}
@@ -0,0 +1,478 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
import type { FileContentResponse, FileTreeEntry, WorkspaceFiles, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
import { RELAYS_ROOT, type RelayDiscoveryFiles } from "./relayDiscovery";
import { defineRelaysPanelElement, relaysPanelTagName } from "./relaysPanelElement";
interface RelaysPanelTestElement extends HTMLElement {
context: WorkspacePanelContext | undefined;
}
declare global {
interface HTMLElementTagNameMap {
"pi-web-relays-panel": RelaysPanelTestElement;
}
}
afterEach(() => {
document.body.replaceChildren();
});
describe("workspace states", () => {
it("asks for a workspace when no context is set", async () => {
const panel = await mountPanel();
expect(shadow(panel).textContent).toContain("Select a workspace.");
});
it("explains the relays convention when the workspace has no relays root", async () => {
// The fake rejects unknown paths with "Path does not exist".
const panel = await mountPanel(panelContext(workspaceFilesFake()));
expect(viewerText(panel)).toContain("No relays in this workspace.");
expect(viewerText(panel)).toContain(`${RELAYS_ROOT}/<name>/`);
});
it("shows the same empty state when the relays root exists but is empty", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, []);
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("No relays in this workspace.");
});
it("surfaces a scan failure with its detail", async () => {
const fake = workspaceFilesFake();
fake.failWith(RELAYS_ROOT, new Error("connection lost"));
const panel = await mountPanel(panelContext(fake));
const error = shadow(panel).querySelector(".status.error");
expect(error?.textContent).toContain("Could not scan workspace relays.");
expect(error?.textContent).toContain("connection lost");
});
});
describe("single relay", () => {
it("auto-opens the relay without a picker and renders its default document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("only-relay", "2026-02-01T00:00:00.000Z")]);
fake.addDirectory(`${RELAYS_ROOT}/only-relay`, [
relayDocument("only-relay", "charter.md"),
relayDocument("only-relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/only-relay/status.md`, "# Status\nAll good.");
fake.addDocument(`${RELAYS_ROOT}/only-relay/charter.md`, "# Charter");
const panel = await mountPanel(panelContext(fake));
expect(picker(panel)).toBeNull();
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("only-relay");
expect(tabNames(panel)).toEqual(["status.md", "charter.md"]);
expect(activeTab(panel)?.textContent).toBe("status.md");
expect(documentText(panel)).toContain("All good.");
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/only-relay/status.md`);
});
it("escapes filesystem-derived names and paths", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("<img src=x>")]);
fake.addDirectory(`${RELAYS_ROOT}/<img src=x>`, [relayDocument("<img src=x>", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/<img src=x>/status.md`, "safe");
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector("img")).toBeNull();
expect(shadow(panel).querySelector(".relay-name")?.textContent).toBe("<img src=x>");
});
});
describe("multiple relays", () => {
it("pre-selects the most recently modified relay and opens another on picker change", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [
relayDirectory("older", "2026-01-01T00:00:00.000Z"),
relayDirectory("newer", "2026-03-01T00:00:00.000Z"),
]);
fake.addDirectory(`${RELAYS_ROOT}/newer`, [relayDocument("newer", "status.md")]);
fake.addDirectory(`${RELAYS_ROOT}/older`, [relayDocument("older", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/newer/status.md`, "newer status");
fake.addDocument(`${RELAYS_ROOT}/older/status.md`, "older status");
const panel = await mountPanel(panelContext(fake));
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/newer`);
expect(documentText(panel)).toBe("newer status");
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/older`;
select.dispatchEvent(new Event("change"));
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
expect(documentText(panel)).toBe("older status");
});
});
describe("document tabs", () => {
it("orders tabs with anchor documents first and loads the clicked tab's document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
relayDocument("relay", "notes.md"),
relayDocument("relay", "log.md"),
relayDocument("relay", "data.json"),
relayDocument("relay", "charter.md"),
relayDocument("relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
const panel = await mountPanel(panelContext(fake));
expect(tabNames(panel)).toEqual(["status.md", "charter.md", "log.md", "data.json", "notes.md"]);
expect(activeTab(panel)?.textContent).toBe("status.md");
tabNamed(panel, "log.md").click();
await flushAsync();
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/relay/log.md`);
expect(activeTab(panel)?.textContent).toBe("log.md");
expect(documentText(panel)).toBe("log body");
});
it("renders all document tabs as direct children of one tab strip", async () => {
// The strip scrolls horizontally instead of wrapping, so every tab must be
// a direct child of the single nav.document-tabs row.
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
relayDocument("relay", "notes.md"),
relayDocument("relay", "log.md"),
relayDocument("relay", "status.md"),
]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
const panel = await mountPanel(panelContext(fake));
const strips = shadow(panel).querySelectorAll("nav.document-tabs");
expect(strips.length).toBe(1);
const strip = strips[0];
expect(strip?.getAttribute("aria-label")).toBe("Relay documents");
const children = [...(strip?.children ?? [])];
expect(children.every((child) => child instanceof HTMLButtonElement)).toBe(true);
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
});
it("shows a truncation notice when the open document is truncated", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "partial log", { truncated: true });
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector(".status.info")?.textContent).toContain("truncated");
expect(documentText(panel)).toBe("partial log");
});
it("explains when a document has no text preview", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "AAAA", { binary: true });
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("Binary file: status.md");
expect(shadow(panel).querySelector("pre.document")).toBeNull();
});
it("explains when the open document vanishes between listing and read", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
// No addDocument: the fake readFile rejects with "Path does not exist".
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("This document no longer exists.");
});
it("explains when a relay has no documents", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, []);
const panel = await mountPanel(panelContext(fake));
expect(viewerText(panel)).toContain("This relay has no documents yet.");
});
});
describe("markdown rendering", () => {
it("renders .md documents as sanitized markdown HTML", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, [
"# Status",
"",
"See [the docs](https://example.com/docs).",
"",
"<script>alert('xss')</script>",
"",
"[click](javascript:alert('xss'))",
].join("\n"));
const panel = await mountPanel(panelContext(fake));
const rendered = shadow(panel).querySelector(".document.markdown");
expect(rendered).not.toBeNull();
expect(shadow(panel).querySelector("pre.document")).toBeNull();
expect(rendered?.querySelector("h1")?.textContent).toBe("Status");
expect(rendered?.querySelector("script")).toBeNull();
const docsLink = rendered?.querySelector('a[href="https://example.com/docs"]');
expect(docsLink?.getAttribute("target")).toBe("_blank");
expect(docsLink?.getAttribute("rel")).toBe("noreferrer noopener");
const unsafeLink = [...rendered?.querySelectorAll("a") ?? []].find((link) => link.textContent === "click");
expect(unsafeLink?.hasAttribute("href")).toBe(false);
});
it("keeps non-markdown documents as escaped preformatted text", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "notes.txt")]);
fake.addDocument(`${RELAYS_ROOT}/relay/notes.txt`, "# not a heading\n<script>alert('xss')</script>");
const panel = await mountPanel(panelContext(fake));
expect(shadow(panel).querySelector(".document.markdown")).toBeNull();
const pre = shadow(panel).querySelector("pre.document");
expect(pre?.textContent).toContain("# not a heading");
expect(shadow(panel).querySelector(".viewer h1")).toBeNull();
expect(shadow(panel).querySelector(".viewer script")).toBeNull();
});
it("keeps the truncation notice ahead of rendered markdown", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "# Partial", { truncated: true });
const panel = await mountPanel(panelContext(fake));
const viewer = shadow(panel).querySelector(".viewer");
expect(viewer?.querySelector(".status.info")?.textContent).toContain("truncated");
expect(viewer?.querySelector(".document.markdown h1")?.textContent).toBe("Partial");
});
});
describe("refresh and context changes", () => {
it("renders Refresh as an icon-only button with an accessible name", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, []);
const panel = await mountPanel(panelContext(fake));
const button = refreshButton(panel);
expect(button.getAttribute("aria-label")).toBe("Refresh");
expect(button.getAttribute("title")).toBe("Refresh");
expect(button.textContent.trim()).toBe("");
const icon = button.querySelector("svg");
expect(icon).not.toBeNull();
expect(icon?.getAttribute("aria-hidden")).toBe("true");
fake.listFiles.mockClear();
button.click();
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
});
it("re-scans on Refresh while keeping the open relay and document", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
]);
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md"), relayDocument("alpha", "log.md")]);
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
fake.addDocument(`${RELAYS_ROOT}/alpha/log.md`, "alpha log");
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
const panel = await mountPanel(panelContext(fake));
const select = picker(panel);
if (select === null) throw new Error("relay picker missing");
select.value = `${RELAYS_ROOT}/alpha`;
select.dispatchEvent(new Event("change"));
await flushAsync();
tabNamed(panel, "log.md").click();
await flushAsync();
expect(documentText(panel)).toBe("alpha log");
fake.listFiles.mockClear();
fake.readFile.mockClear();
refreshButton(panel).click();
await flushAsync();
expect(fake.listFiles).toHaveBeenCalledWith(RELAYS_ROOT);
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha`);
expect(fake.readFile).toHaveBeenCalledWith(`${RELAYS_ROOT}/alpha/log.md`);
expect(picker(panel)?.value).toBe(`${RELAYS_ROOT}/alpha`);
expect(activeTab(panel)?.textContent).toBe("log.md");
expect(documentText(panel)).toBe("alpha log");
});
it("does not rescan when the same workspace context is set again, but rescans for a new workspace", async () => {
const fake = workspaceFilesFake();
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "status.md")]);
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status");
const panel = await mountPanel(panelContext(fake));
const callsAfterMount = fake.listFiles.mock.calls.length;
panel.context = panelContext(fake);
await flushAsync();
expect(fake.listFiles.mock.calls.length).toBe(callsAfterMount);
panel.context = panelContext(fake, "ws-2");
await flushAsync();
expect(fake.listFiles.mock.calls.length).toBeGreaterThan(callsAfterMount);
});
});
interface WorkspaceFilesFake {
files: WorkspaceFiles;
listFiles: Mock<RelayDiscoveryFiles["listFiles"]>;
readFile: Mock<RelayDiscoveryFiles["readFile"]>;
addDirectory(path: string, entries: FileTreeEntry[]): void;
addDocument(path: string, content: string, overrides?: Partial<FileContentResponse>): void;
failWith(path: string, error: Error): void;
}
/** In-memory WorkspaceFiles fake: unknown paths reject with "Path does not exist", like the real helper. */
function workspaceFilesFake(): WorkspaceFilesFake {
const directories = new Map<string, FileTreeEntry[]>();
const documents = new Map<string, FileContentResponse>();
const failures = new Map<string, Error>();
const listFiles = vi.fn<RelayDiscoveryFiles["listFiles"]>((path) => {
const failure = failures.get(path);
if (failure !== undefined) return Promise.reject(failure);
const entries = directories.get(path);
if (entries === undefined) return Promise.reject(new Error("Path does not exist"));
return Promise.resolve({ path, entries, scannedAt: "2026-01-01T00:00:00.000Z", truncated: false });
});
const readFile = vi.fn<RelayDiscoveryFiles["readFile"]>((path) => {
const failure = failures.get(path);
if (failure !== undefined) return Promise.reject(failure);
const file = documents.get(path);
if (file === undefined) return Promise.reject(new Error("Path does not exist"));
return Promise.resolve(file);
});
return {
files: {
listFiles,
readFile,
// The panel is read-only; the mutating helpers exist only to satisfy WorkspaceFiles.
writeFile: () => Promise.reject(new Error("writeFile not used")),
deleteFile: () => Promise.reject(new Error("deleteFile not used")),
moveFile: () => Promise.reject(new Error("moveFile not used")),
},
listFiles,
readFile,
addDirectory: (path, entries) => { directories.set(path, entries); },
addDocument: (path, content, overrides = {}) => {
documents.set(path, {
path,
encoding: "utf8",
size: content.length,
modifiedAt: "2026-01-01T00:00:00.000Z",
content,
truncated: false,
binary: false,
...overrides,
});
},
failWith: (path, error) => { failures.set(path, error); },
};
}
function panelContext(fake: WorkspaceFilesFake, workspaceId = "ws-1"): WorkspacePanelContext {
return {
machine: { id: "machine-1", name: "Local", kind: "local" },
workspace: { id: workspaceId, projectId: "project-1", path: "/repo", label: "repo", isMain: true, isGitRepo: true, isGitWorktree: false },
files: fake.files,
host: { requestRender: () => undefined },
prompt: { insertText: () => undefined, getText: () => "", getSelection: () => null },
terminal: { open: () => undefined, runCommand: () => Promise.reject(new Error("terminal not used")) },
};
}
async function mountPanel(context?: WorkspacePanelContext): Promise<RelaysPanelTestElement> {
defineRelaysPanelElement();
const panel = document.createElement(relaysPanelTagName);
document.body.append(panel);
if (context !== undefined) panel.context = context;
await flushAsync();
return panel;
}
/** One macrotask drains the full microtask chain of the panel's immediate-fake scans. */
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function relayDirectory(name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${name}`, type: "directory", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function relayDocument(relayName: string, name: string, modifiedAt?: string): FileTreeEntry {
return { name, path: `${RELAYS_ROOT}/${relayName}/${name}`, type: "file", ...(modifiedAt === undefined ? {} : { modifiedAt }) };
}
function shadow(panel: RelaysPanelTestElement): ShadowRoot {
const root = panel.shadowRoot;
if (root === null) throw new Error("panel has no shadow root");
return root;
}
function viewerText(panel: RelaysPanelTestElement): string {
return shadow(panel).querySelector(".viewer")?.textContent ?? "";
}
/** Text of the open document viewer, whether it rendered markdown or a <pre> block. */
function documentText(panel: RelaysPanelTestElement): string | null {
return shadow(panel).querySelector(".viewer .document")?.textContent.trim() ?? null;
}
function picker(panel: RelaysPanelTestElement): HTMLSelectElement | null {
const select = shadow(panel).querySelector("select[data-relay-picker]");
return select instanceof HTMLSelectElement ? select : null;
}
function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
const button = shadow(panel).querySelector("button[data-refresh]");
if (!(button instanceof HTMLElement)) throw new Error("refresh button missing");
return button;
}
function tabNames(panel: RelaysPanelTestElement): string[] {
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
}
function activeTab(panel: RelaysPanelTestElement): Element | null {
return shadow(panel).querySelector("button[data-document-path].active");
}
function tabNamed(panel: RelaysPanelTestElement, name: string): HTMLElement {
const tab = [...shadow(panel).querySelectorAll("button[data-document-path]")].find((candidate) => candidate.textContent === name);
if (!(tab instanceof HTMLElement)) throw new Error(`tab "${name}" not found`);
return tab;
}
+358
View File
@@ -0,0 +1,358 @@
import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
import { isMarkdownDocumentPath, renderRelayDocumentHtml } from "./markdownDocument.js";
import {
defaultRelayDocument,
listRelayDocuments,
listWorkspaceRelays,
readRelayDocument,
RELAYS_ROOT,
type RelayDocumentContent,
type RelayDocumentsListing,
type RelaysListing,
} from "./relayDiscovery.js";
export const relaysPanelTagName = "pi-web-relays-panel";
export function defineRelaysPanelElement(): void {
if (!customElements.get(relaysPanelTagName)) customElements.define(relaysPanelTagName, PiWebRelaysPanel);
}
/** Selection a scan should restore after reloading, when the entries still exist. */
interface RelaySelection {
relayPath?: string | undefined;
documentPath?: string | undefined;
}
/**
* Read-only relay browser: relay picker (auto-opens a single relay), one tab
* per relay document, and a document viewer rendering markdown documents as
* sanitized HTML and everything else as preformatted text. All async loads
* flow through scanToken so stale responses for a previous workspace or
* selection never overwrite newer state.
*/
class PiWebRelaysPanel extends HTMLElement {
private contextValue: WorkspacePanelContext | undefined;
private listing: RelaysListing | undefined;
private selectedRelayPath: string | undefined;
private documents: RelayDocumentsListing | undefined;
private selectedDocumentPath: string | undefined;
private documentContent: RelayDocumentContent | undefined;
private scanToken = 0;
private readonly root: ShadowRoot;
constructor() {
super();
this.root = this.attachShadow({ mode: "open" });
}
set context(value: WorkspacePanelContext | undefined) {
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
const nextKey = value === undefined ? undefined : contextKey(value);
this.contextValue = value;
// Parent app updates should not rescan or rebuild this shadow DOM for the
// same workspace (mirrors the workspace-tasks panel).
if (previousKey === nextKey) return;
if (value === undefined) {
this.resetScanState();
this.render();
return;
}
void this.scan(value, {});
}
connectedCallback(): void {
this.render();
}
/** Rescan relays, then reload the selected relay's documents and the open document. */
private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise<void> {
const token = ++this.scanToken;
this.resetScanState();
this.render();
const listing = await listWorkspaceRelays(context.files);
if (!this.isCurrentScan(context, token)) return;
this.listing = listing;
// listWorkspaceRelays returns most recently modified first, so the first
// relay is the default pre-selection.
const relay = listing.kind === "loaded"
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
: undefined;
this.selectedRelayPath = relay?.path;
if (relay === undefined) {
this.render();
return;
}
await this.loadDocuments(context, token, relay.path, selection.documentPath);
}
private async openRelay(context: WorkspacePanelContext, relayPath: string): Promise<void> {
const token = ++this.scanToken;
this.selectedRelayPath = relayPath;
await this.loadDocuments(context, token, relayPath, undefined);
}
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
const token = ++this.scanToken;
this.selectedDocumentPath = documentPath;
await this.loadDocumentContent(context, token, documentPath);
}
private async loadDocuments(context: WorkspacePanelContext, token: number, relayPath: string, preferredDocumentPath: string | undefined): Promise<void> {
this.documents = undefined;
this.selectedDocumentPath = undefined;
this.documentContent = undefined;
this.render();
const documents = await listRelayDocuments(context.files, relayPath);
if (!this.isCurrentScan(context, token)) return;
this.documents = documents;
const document = documents.kind === "loaded"
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
: undefined;
this.selectedDocumentPath = document?.path;
if (document === undefined) {
this.render();
return;
}
await this.loadDocumentContent(context, token, document.path);
}
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
this.documentContent = undefined;
this.render();
const content = await readRelayDocument(context.files, documentPath);
if (!this.isCurrentScan(context, token)) return;
this.documentContent = content;
this.render();
}
private refresh(): void {
const context = this.contextValue;
if (context === undefined) return;
void this.scan(context, { relayPath: this.selectedRelayPath, documentPath: this.selectedDocumentPath });
}
private resetScanState(): void {
this.listing = undefined;
this.selectedRelayPath = undefined;
this.documents = undefined;
this.selectedDocumentPath = undefined;
this.documentContent = undefined;
}
private isCurrentScan(context: WorkspacePanelContext, token: number): boolean {
return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
}
private render(): void {
const context = this.contextValue;
if (context === undefined) {
this.root.innerHTML = `${relaysStyles()}<section class="empty">Select a workspace.</section>`;
return;
}
this.root.innerHTML = `
${relaysStyles()}
<section class="toolbar">
<strong>Relays</strong>
<span class="toolbar-actions">
${this.renderRelayPicker()}
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
</span>
</section>
${this.renderDocumentTabs()}
<section class="viewer">${this.renderViewer()}</section>
`;
this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => {
this.refresh();
});
this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => {
const picker = event.target;
if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value);
});
for (const tab of this.root.querySelectorAll("button[data-document-path]")) {
tab.addEventListener("click", () => {
const documentPath = tab.getAttribute("data-document-path");
if (documentPath !== null) void this.openDocument(context, documentPath);
});
}
}
private renderRelayPicker(): string {
const listing = this.listing;
if (listing?.kind !== "loaded" || listing.relays.length === 0) return "";
// A single relay opens immediately; a one-option picker would be noise.
if (listing.relays.length === 1) {
const relay = listing.relays[0];
return relay === undefined ? "" : `<span class="relay-name" title="${escapeAttr(relay.path)}">${escapeHtml(relay.name)}</span>`;
}
const options = listing.relays.map((relay) => {
const selected = relay.path === this.selectedRelayPath ? " selected" : "";
return `<option value="${escapeAttr(relay.path)}"${selected}>${escapeHtml(relay.name)}</option>`;
}).join("");
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
}
private renderDocumentTabs(): string {
const documents = this.documents;
if (documents?.kind !== "loaded" || documents.documents.length === 0) return "";
const tabs = documents.documents.map((document) => {
const active = document.path === this.selectedDocumentPath;
return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
}).join("");
return `<nav class="document-tabs" aria-label="Relay documents">${tabs}</nav>`;
}
private renderViewer(): string {
const listing = this.listing;
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail);
if (listing.kind === "missing" || listing.relays.length === 0) return renderEmptyState();
return this.renderSelectedRelay();
}
private renderSelectedRelay(): string {
const documents = this.documents;
if (documents === undefined) return `<p class="muted">Loading relay documents…</p>`;
if (documents.kind === "unavailable") return renderErrorState("Could not list this relay's documents.", documents.detail);
if (documents.kind === "missing") {
return `<div class="empty-state"><strong>This relay no longer exists.</strong><p>Click Refresh to rescan ${escapeHtml(RELAYS_ROOT)}.</p></div>`;
}
if (documents.documents.length === 0) {
return `
<div class="empty-state">
<strong>This relay has no documents yet.</strong>
<p>Relay packets usually contain <code>status.md</code>, <code>charter.md</code>, and <code>log.md</code>.</p>
</div>
`;
}
return this.renderSelectedDocument();
}
private renderSelectedDocument(): string {
const documentPath = this.selectedDocumentPath;
const content = this.documentContent;
if (documentPath === undefined) return `<p class="muted">Select a document.</p>`;
if (content === undefined) return `<p class="muted">Loading ${escapeHtml(documentName(documentPath))}…</p>`;
if (content.kind === "unavailable") return renderErrorState("Could not read this document.", content.detail);
if (content.kind === "missing") {
return `<div class="empty-state"><strong>This document no longer exists.</strong><p>Click Refresh to rescan the relay.</p></div>`;
}
if (content.binary) {
return `<div class="empty-state"><strong>Binary file: ${escapeHtml(documentName(documentPath))}</strong><p>Binary documents have no text preview.</p></div>`;
}
const truncation = content.truncated
? `<div class="status info">This document is truncated — only the beginning is shown.</div>`
: "";
if (isMarkdownDocumentPath(documentPath)) {
return `${truncation}<div class="document markdown">${renderRelayDocumentHtml(content.content)}</div>`;
}
return `${truncation}<pre class="document">${escapeHtml(content.content)}</pre>`;
}
}
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
function refreshIconSvg(): string {
return `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
</svg>
`;
}
function contextKey(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
function documentName(path: string): string {
return path.slice(path.lastIndexOf("/") + 1);
}
function renderEmptyState(): string {
return `
<div class="empty-state">
<strong>No relays in this workspace.</strong>
<p>Relay packets live in <code>${escapeHtml(RELAYS_ROOT)}/&lt;name&gt;/</code>. This workspace has none yet.</p>
</div>
`;
}
function renderErrorState(message: string, detail: string): string {
return `<div class="status error"><strong>${escapeHtml(message)}</strong><pre>${escapeHtml(detail)}</pre></div>`;
}
function relaysStyles(): string {
return `
<style>
:host { display: contents; }
/* Toolbar and tab strip are panel chrome: they must never flex-shrink
(the app container is a fixed-height flex column; with shrink enabled
the viewer's huge content basis starves them down to a sliver once a
tall document renders). The viewer absorbs all shrinking instead. */
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
/* Bottom padding (not viewer margin) so the gap below the tabs persists
when the viewer's content scrolls up against its top edge. */
.document-tabs { flex: 0 0 auto; display: flex; flex-wrap: nowrap; gap: 6px; padding: 8px 12px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.viewer { flex: 1 1 auto; box-sizing: border-box; display: grid; align-content: start; gap: 12px; min-height: 0; overflow: auto; padding: 12px; }
/* Grid children default to min-width: auto; without these caps a wide code
block or table would silently stretch the whole viewer track. */
.viewer > * { box-sizing: border-box; min-width: 0; max-width: 100%; }
button, select { border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); font: inherit; }
button { cursor: pointer; padding: 6px 10px; }
button.icon-button { flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; }
button.icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
select { min-width: 0; max-width: 240px; padding: 5px 6px; }
.document-tab { flex: 0 0 auto; white-space: nowrap; font-size: 12px; padding: 4px 10px; }
.document-tab.active { border-color: var(--pi-accent-border); background: var(--pi-accent); color: var(--pi-bg); }
code, pre { border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { padding: 2px 5px; }
pre { margin: 0; overflow: auto; padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
.document.markdown { line-height: 1.5; overflow-wrap: anywhere; }
.document.markdown p, .document.markdown ul, .document.markdown ol, .document.markdown pre, .document.markdown blockquote, .document.markdown .table-scroll { margin: 0 0 10px; }
.document.markdown > :last-child { margin-bottom: 0; }
.document.markdown h1, .document.markdown h2, .document.markdown h3, .document.markdown h4 { line-height: 1.25; margin: 14px 0 8px; }
.document.markdown h1:first-child, .document.markdown h2:first-child, .document.markdown h3:first-child, .document.markdown h4:first-child { margin-top: 0; }
.document.markdown h1 { font-size: 18px; }
.document.markdown h2 { font-size: 16px; }
.document.markdown h3 { font-size: 14px; }
.document.markdown h4 { font-size: 13px; }
.document.markdown ul, .document.markdown ol { padding-left: 22px; }
.document.markdown li + li { margin-top: 3px; }
.document.markdown pre { white-space: pre; overflow-wrap: normal; }
.document.markdown pre code { border: 0; background: transparent; padding: 0; }
.document.markdown img { box-sizing: border-box; max-width: 100%; }
.document.markdown blockquote { border-left: 3px solid var(--pi-border-muted); color: var(--pi-muted); padding-left: 10px; }
.document.markdown a { color: var(--pi-accent); }
.document.markdown .table-scroll { max-width: 100%; overflow-x: auto; }
/* Cells wrap at word boundaries only: a wide table keeps its natural width
and scrolls inside .table-scroll instead of being squeezed unreadably. */
.document.markdown table { border-collapse: collapse; overflow-wrap: normal; }
.document.markdown th, .document.markdown td { border: 1px solid var(--pi-border-muted); padding: 4px 8px; }
.status pre { margin-top: 8px; }
.muted { color: var(--pi-muted); }
.empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
.empty-state p { margin: 6px 0 0; }
.status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
.status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
.status.error { border-color: var(--pi-danger); color: var(--pi-danger); }
.empty { padding: 16px; color: var(--pi-muted); }
</style>
`;
}
function escapeHtml(value: unknown): string {
return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function escapeAttr(value: unknown): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
+24
View File
@@ -0,0 +1,24 @@
# Vendored dependencies
## marked (`marked.esm.js`)
- **Source:** `node_modules/marked/lib/marked.esm.js` — marked v18.0.6, the
version the repo pins (`"marked": "^18.0.6"` in the root `package.json`).
- **License:** MIT — copyright (c) 2018-2026 MarkedJS, (c) 2011-2018
Christopher Jeffrey. The attribution header at the top of `marked.esm.js` is
preserved; see also `node_modules/marked/LICENSE.md`.
- **Why vendored:** bundled plugins load in the browser as standalone ES
modules and cannot resolve bare package specifiers. The plugin therefore
ships the exact marked build the repo already depends on, rather than
hand-rolling a markdown subset or adding a markdown helper to the plugin API.
- **Local modification:** the trailing `//# sourceMappingURL=marked.esm.js.map`
comment was removed because the (much larger) source map is not vendored.
Everything else is byte-identical to the published file.
- **`marked.esm.d.ts`** is a hand-written minimal declaration covering only the
surface `markdownDocument.ts` uses. The plugin build
(`scripts/build-plugins.mjs`) skips `.d.ts` files and copies `.js` assets
verbatim, so the vendored module ships as-is.
**Updating:** after a `marked` upgrade in the root `package.json`, copy the new
`node_modules/marked/lib/marked.esm.js` here, re-apply the sourceMappingURL
removal, and update this note.
+28
View File
@@ -0,0 +1,28 @@
/**
* Minimal declarations for the vendored marked ESM build (./marked.esm.js).
* Covers only the surface markdownDocument.ts uses; widen it if usage grows.
* The plugin build copies the .js verbatim and skips this file, so these
* declarations exist for tsc and editor tooling only.
*/
export interface MarkedHtmlToken {
text: string;
}
export interface MarkedRenderer {
html: (token: MarkedHtmlToken) => string;
}
export interface MarkedParseOptions {
async?: false;
breaks?: boolean;
gfm?: boolean;
renderer?: MarkedRenderer;
}
export interface Marked {
Renderer: new () => MarkedRenderer;
parse(source: string, options?: MarkedParseOptions): string;
}
export const marked: Marked;
File diff suppressed because one or more lines are too long