feat(dsh-plugin-browserskill): add the web client half with a browser_screenshot toolview

Make the package dual-face: a dsh.client declaration (platform 'web')
plus an exports["./client"] bundle built in dsh's closure-factory shape
(window.__ModuleLoader__.load handoff, platform modules external,
everything else inlined, CSS Modules via lightningcss).

The client registers a keyed 'tool.call.toolview' view for
browser_screenshot that keeps the terminal block (command + output) and,
when the settled result carries an image block, resolves the durable
attachment through the client session's authorized readAttachment RPC
and renders it with the shared MessageImage thumbnail/lightbox atoms.
Path-only results render unchanged; every other browser_* tool keeps
the stock terminal card.

Tests: view-model derivation (running/ok/error, image vs path-only),
component rendering through @testing-library/react + happy-dom, and the
registration key. biome.json now excludes the package's lib/ build
output (same treatment as dist/).
This commit is contained in:
BB-fat
2026-08-14 02:54:33 +00:00
parent 0771292336
commit 6b96fcca32
12 changed files with 2642 additions and 25 deletions
@@ -0,0 +1,57 @@
/* browser_screenshot toolview: disclosure row over a terminal block plus the
screenshot image. Semantic tokens only (--dsw-alias-*), no literal colors. */
.card {
display: flex;
flex-direction: column;
}
.summary {
min-width: 0;
margin-left: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.card[data-state="error"] .summary {
color: var(--dsw-alias-label-error);
}
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 22px;
}
.image-wrap {
display: flex;
}
.inspect-button {
align-self: flex-start;
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.inspect-button:hover {
color: var(--dsw-alias-label-secondary);
}
.inspect-button:focus-visible {
outline: 2px solid var(--dsw-alias-border-focus);
outline-offset: 2px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
.inspect-button {
transition: none;
}
}
@@ -0,0 +1,161 @@
// browser_screenshot keyed toolview: the terminal card's command line and
// output stay exactly as the host renders them, and a result carrying an
// image block additionally renders the screenshot itself (thumbnail with the
// shared click-to-open lightbox). Pure function of the frozen call/result
// slice — live and replay paths render identically.
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
import {
type ImageLoader,
MessageImage,
type MessageImageLabels,
} from "@deepseek-ai/dsh-client-ui-attachment";
import {
DisclosureRow,
StateDot,
type StateDotState,
TerminalBlock,
type TerminalBlockLabels,
} from "@deepseek-ai/dsh-client-ui-primitives";
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
import { useState } from "react";
import css from "./ScreenshotToolView.module.css";
/** Resolved loader bound to the owning session at registration time. */
export type ScreenshotImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
export type ScreenshotToolViewProps = ToolCallViewProps & {
loadImage: ScreenshotImageLoader;
};
type ViewState = "running" | "ok" | "error";
interface ViewModel {
readonly state: ViewState;
readonly command: string;
readonly output: string | null;
readonly image: ImageAttachmentRef | null;
readonly summary: string;
}
const TERMINAL_LABELS: Partial<TerminalBlockLabels> = {
running: "Running",
failed: "Failed",
done: "Done",
copy: "Copy",
copied: "Copied",
noOutput: "No output",
collapseAria: "Collapse output",
collapse: "Collapse",
expandAria: (hidden) => `Expand the remaining ${hidden} output lines`,
expand: (hidden) => `… ${hidden} more lines`,
};
const IMAGE_LABELS: MessageImageLabels = {
image: "screenshot",
open: "Open the original screenshot",
openNamed: (label) => `Open screenshot ${label}`,
loading: "Loading…",
loadFailed: "Load failed — retry",
lightbox: { dialog: "Screenshot preview", close: "Close preview" },
};
function firstLine(text: string): string {
const newline = text.indexOf("\n");
return newline === -1 ? text : text.slice(0, newline);
}
/** Rebuild the command line from the logged arguments (mirrors the host presenter). */
function commandOf(argsRaw: string, callId: string): string {
try {
const args = JSON.parse(argsRaw) as { session?: unknown; ref?: unknown };
const parts = ["bsk", "screenshot", "--session"];
parts.push(
typeof args.session === "string" && args.session !== "" ? args.session : "(current)",
);
if (typeof args.ref === "string" && args.ref !== "") parts.push("--ref", args.ref);
return parts.join(" ");
} catch {
// Streaming can expose a truncated JSON prefix; it is still the best label.
return argsRaw === "" ? `bsk screenshot (${callId})` : `bsk screenshot ${firstLine(argsRaw)}`;
}
}
/** Derive the display model from the frozen block only. */
export function viewModelOf(block: ToolCallViewProps["block"]): ViewModel {
const settled = "kind" in block;
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? "";
const command = commandOf(argsRaw, block.callId);
if (!settled) {
return { state: "running", command, output: null, image: null, summary: command };
}
const textBlock = block.content.find((item) => item.type === "text");
const imageBlock = block.content.find((item) => item.type === "image");
const output = textBlock !== undefined && textBlock.type === "text" ? textBlock.text : null;
const image =
imageBlock !== undefined && imageBlock.type === "image" ? imageBlock.attachment : null;
const state: ViewState = block.isError ? "error" : "ok";
const summary = output !== null ? firstLine(output) : command;
return { state, command, output, image, summary };
}
function dotState(state: ViewState): StateDotState {
switch (state) {
case "running":
return "ongoing";
case "error":
return "error";
default:
return "done";
}
}
/**
* Render one browser_screenshot call: a disclosure row over a terminal block
* (command + output) plus the screenshot image when the result carries one.
*/
export function ScreenshotToolView({ block, cwd, inspect, loadImage }: ScreenshotToolViewProps) {
const model = viewModelOf(block);
const [expanded, setExpanded] = useState(false);
const expandable = model.state === "running" || model.output !== null || model.image !== null;
const open = expanded && expandable;
return (
<div className={css.card} data-tool="browser_screenshot" data-state={model.state}>
<DisclosureRow
icon={<StateDot state={dotState(model.state)} />}
title="Screenshot"
open={open}
expandable={expandable}
onToggle={() => setExpanded((value) => !value)}
expandOnRowClick
previewChevron
collapsedContent={<span className={css.summary}>{model.summary}</span>}
>
<div className={css.body}>
<TerminalBlock
command={model.command}
cwd={cwd ?? undefined}
output={model.output ?? undefined}
running={model.state === "running"}
labels={TERMINAL_LABELS}
/>
{model.image !== null ? (
<div className={css["image-wrap"]}>
<MessageImage
attachment={model.image}
load={loadImage}
variant="single"
labels={IMAGE_LABELS}
/>
</div>
) : null}
{inspect !== undefined ? (
<button type="button" className={css["inspect-button"]} onClick={inspect}>
Inspect
</button>
) : null}
</div>
</DisclosureRow>
</div>
);
}
@@ -0,0 +1,60 @@
/**
* dsh-plugin-browserskill browser half: registers the `browser_screenshot`
* keyed toolview (`tool.call.toolview` slot) so screenshot results render the
* captured image inline in the Web UI instead of a bare JSON block. Every
* other browser_* tool keeps the stock terminal card.
*
* The image bytes never enter the session log: the view resolves the durable
* attachment reference through the client session's authorized
* `readAttachment` RPC and hands a blob URL to the shared MessageImage atom.
*/
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client";
import { createElement } from "react";
import { ScreenshotToolView } from "./ScreenshotToolView";
/** Required services: the slot registry plus session-scoped attachment reads. */
export const inject = ["slots", "sessions"];
/** Resolve one durable image attachment into a browser blob URL. */
async function loadSessionImage(
sessions: ISessions,
sessionId: SessionId,
attachment: ImageAttachmentRef,
): Promise<string> {
const session = sessions.binding(sessionId)?.session;
if (session === undefined) {
throw new Error(`screenshot toolview: session "${String(sessionId)}" is not bound`);
}
const result = await session.readAttachment(attachment.attachmentId);
if (!result.ok) {
throw new Error(
`screenshot toolview: readAttachment failed: ${result.error.code}: ${result.error.message}`,
);
}
const bytes = Uint8Array.from(result.value.data);
return URL.createObjectURL(
new Blob([bytes.buffer as ArrayBuffer], { type: result.value.attachment.mediaType }),
);
}
/**
* Client plugin body: register the keyed view with its session-bound loader.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.get("sessions") as unknown as ISessions;
ctx.slots.inject("tool.call.toolview", () =>
ctx.slots.register(
{ name: "tool.call.toolview", key: "browser_screenshot" },
(props: ToolCallViewProps) =>
createElement(ScreenshotToolView, {
...props,
loadImage: (attachment: ImageAttachmentRef) =>
loadSessionImage(sessions, props.sessionId, attachment),
}),
),
);
}