import type { ToolView, View, ViewContext } from "@berry/api/web.ts"; import * as j from "@char/justin"; const snapshot = j.obj({ tab: j.string, url: j.string, title: j.string, text: j.string, elements: j.array(j.obj({ ref: j.string, role: j.string, name: j.string, value: j.optional(j.string), disabled: j.optional(j.boolean), checked: j.optional(j.boolean), options: j.optional(j.array(j.obj({ label: j.string, value: j.string, selected: j.boolean }))), })), truncated: j.boolean, frames: j.array(j.obj({ url: j.string, title: j.string })), diagnostics: j.array(j.string), }); const resultOf = j.validation.compile(j.obj({ snapshot: j.optional(j.union(snapshot, j.null)), tabs: j.optional(j.array(j.obj({ tab: j.string, url: j.string, title: j.string }))), })); const dataOf = j.validation.compile(j.obj({ active: j.boolean, tabs: j.array(snapshot), history: j.array(j.obj({ action: j.string, tab: j.string, url: j.string, at: j.number })), screenshots: j.array( j.obj({ tab: j.string, image: j.obj({ type: j.string, data: j.string, mimeType: j.string }) }), ), })); const argsOf = j.validation.compile( j.obj({ action: j.string, tab: j.optional(j.string), url: j.optional(j.string) }), ); export function summary(args: unknown): string | null { const value = argsOf(args).value; return value ? [value.action, value.tab, value.url].filter(Boolean).join(" · ") : null; } function node( tag: K, text?: string, ): HTMLElementTagNameMap[K] { const element = document.createElement(tag); if (text !== undefined) element.textContent = text; return element; } function renderSnapshot(value: j.Infer): HTMLElement { const element = node("section"); const link = node("a", value.url); if (/^https?:\/\//.test(value.url)) { link.href = value.url; link.target = "_blank"; link.rel = "noopener noreferrer"; } const list = node("ul"); for (const item of value.elements) { list.append( node( "li", `${item.ref} · ${item.role} ${JSON.stringify(item.name)}${ item.value !== undefined ? ` = ${JSON.stringify(item.value)}` : "" }${item.disabled ? " (disabled)" : ""}${ item.checked !== undefined ? ` (${item.checked ? "checked" : "unchecked"})` : "" }${ item.options ? ` — ${ item.options.map((option) => `${option.label}: ${option.value}${option.selected ? " ✓" : ""}` ).join(", ") }` : "" }`, ), ); } element.append( node("h3", `${value.tab} · ${value.title || "Untitled"}`), link, node("h4", "Page text"), node("pre", value.text), node("h4", "Elements"), list, ); if (value.truncated) element.append(node("p", "Snapshot truncated.")); if (value.frames.length) { element.append(node( "p", `Embedded frames (not inspected): ${ value.frames.map((frame) => frame.title || frame.url).join(", ") }`, )); } if (value.diagnostics.length) { element.append(node("h4", "Console & network"), node("pre", value.diagnostics.join("\n"))); } return element; } export function browserToolView(): ToolView { const element = node("div"); element.className = "tool-body browser-pane"; return { element, update(call) { const result = resultOf(call.result).value; element.replaceChildren( ...(result?.snapshot ? [renderSnapshot(result.snapshot)] : [node( "pre", call.error || call.output || (result ? result.tabs?.map((tab) => `${tab.tab} · ${tab.title || tab.url}`).join("\n") || "No open tabs." : ""), )]), ); }, }; } export function browserView(): View { const element = node("div"); element.className = "browser-pane"; const toolbar = node("div"); const refresh = node("button", "Refresh"); refresh.type = "button"; const close = node("button", "Close browser"); close.type = "button"; close.disabled = true; const status = node("p"); status.setAttribute("role", "status"); const tabs = node("div"); tabs.setAttribute("role", "group"); tabs.setAttribute("aria-label", "Browser tabs"); const content = node("div"); const history = node("ol"); toolbar.append(refresh, close, status); element.append(toolbar, tabs, content, node("h3", "Recent actions"), history); let context: ViewContext | undefined; let selected: string | undefined; let controller: AbortController | undefined; let previous: string | undefined; let disposed = false; const load = async () => { if (!context || disposed) return; controller?.abort(); const request = controller = new AbortController(); try { const data = dataOf(await context.request("view", {}, { signal: request.signal })).value; if (request.signal.aborted || disposed) return; if (!data) throw new Error("Invalid browser view response."); close.disabled = !data.active; status.textContent = data.active ? "[TODO: active browser session help]" : "[TODO: historical browser snapshots help]"; if (!data.tabs.some((tab) => tab.tab === selected)) selected = data.tabs.at(-1)?.tab; const draw = () => { const value = data.tabs.find((tab) => tab.tab === selected); const screenshot = data.screenshots.find((entry) => entry.tab === selected)?.image; const children: Node[] = []; if (screenshot?.mimeType === "image/png") { const image = node("img"); image.src = `data:image/png;base64,${screenshot.data}`; image.alt = `Latest screenshot of ${value?.title || selected}`; children.push(image); } if (value) children.push(renderSnapshot(value)); else children.push(node("p", "open a page to get started")); content.replaceChildren(...children); tabs.replaceChildren(...data.tabs.map((tab) => { const button = node("button", `${tab.tab} · ${tab.title || tab.url}`); button.type = "button"; button.setAttribute("aria-pressed", String(tab.tab === selected)); button.addEventListener("click", () => { selected = tab.tab; draw(); }); return button; })); }; draw(); history.replaceChildren( ...data.history.toReversed().map((item) => node("li", `${item.tab} ${item.action}${item.url ? ` · ${item.url}` : ""}`) ), ); } catch (error) { if (!request.signal.aborted && !disposed) status.textContent = String(error); } }; refresh.addEventListener("click", () => void load()); close.addEventListener("click", async () => { if (!context) return; close.disabled = true; try { await context.request("close", {}); } catch (error) { if (!disposed) status.textContent = String(error); close.disabled = false; return; } await load(); }); return { element, update(next) { if (context?.tid !== next.tid) selected = undefined; context = next; const key = `${next.tid}:${next.latest}`; if (key !== previous) { previous = key; void load(); } }, dispose() { disposed = true; controller?.abort(); }, }; }