diff --git a/CONTEXT.md b/CONTEXT.md index 90c8b3d..a402561 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,3 +110,11 @@ A user-created directory in the DSH user preset root. It remains visible when the bundle hides the shipped root. A same-id preset is overwritten only after interactive confirmation or an explicit `web dsh sync --yes`. _Avoid_: Generated compatible preset, shipped preset + +**Research Tool Row**: +The compact conversation entry for one DSH Research Operation, showing its identity, target, progress, and selected request details. It is distinct from the operation itself and from the settings used to configure providers. +_Avoid_: Research Operation, provider card + +**Research Result Details**: +The information a user opens from a Research Tool Row to inspect the request and its result. It may include operation-specific parameters, a result summary, and the original returned text. +_Avoid_: Settings, provider configuration diff --git a/packages/dsh-web/README.md b/packages/dsh-web/README.md index ff91744..da89e5b 100644 --- a/packages/dsh-web/README.md +++ b/packages/dsh-web/README.md @@ -40,6 +40,17 @@ The profile patch disables the official DSH Web registry, official search and fetch providers, and official `tool-web`; it does not load or depend on the official `@deepseek-ai/dsh-web` package. +Research calls use compact native disclosure rows. Each row shows the request +URL, query, or lookup target and non-default options such as browser rendering, +wait time, navigation mode, section, or documentation topic. Expand a row to +inspect its complete recorded input and returned text in independently +scrollable sections. Running and failed calls retain their status; long +results stay inside the result pane instead of growing the conversation. + +Guion's client registers these request-aware views ahead of the built-in Web +views. The built-in views are internal children of DSH's required tool UI +plugin, so the Host bundle patch cannot disable them independently. + The Guion schemas are complete and shared by every compatible preset: `web_search` takes one to four trimmed queries and preserves concurrent, deterministic partial results; `web_fetch` takes `mode: "auto" | "full" | diff --git a/packages/dsh-web/src/client.css b/packages/dsh-web/src/client.css deleted file mode 100644 index 0f90e30..0000000 --- a/packages/dsh-web/src/client.css +++ /dev/null @@ -1,182 +0,0 @@ -.guionai-web__tool-card { - display: grid; - max-width: 560px; - gap: 10px; - padding: 12px 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 10px; - background: var(--dsw-specific-input-major); -} - -.guionai-web__tool-heading { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.guionai-web__state-dot { - flex: none; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--dsw-alias-label-tertiary); -} - -.guionai-web__tool-card[data-state="success"] .guionai-web__state-dot { - background: var(--dsw-alias-state-success-primary); -} - -.guionai-web__tool-card[data-state="error"] .guionai-web__state-dot { - background: var(--dsw-alias-state-error-primary); -} - -.guionai-web__tool-card[data-state="running"] .guionai-web__state-dot { - background: var(--dsw-alias-interactive-border-focus); -} - -.guionai-web__tool-title { - min-width: 0; - overflow: hidden; - color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 20px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.guionai-web__tool-state { - margin-left: auto; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; -} - -.guionai-web__tool-summary, -.guionai-web__result-count, -.guionai-web__excerpt, -.guionai-web__tool-error { - margin: 0; - color: var(--dsw-alias-label-secondary); - font-size: 13px; - line-height: 20px; -} - -.guionai-web__tool-summary { - overflow: hidden; - color: var(--dsw-alias-label-tertiary); - text-overflow: ellipsis; - white-space: nowrap; -} - -.guionai-web__tool-details { - display: flex; - flex-wrap: wrap; - gap: 6px 14px; - margin: 0; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; -} - -.guionai-web__tool-details div { - display: flex; - gap: 4px; -} - -.guionai-web__tool-details dt::after { - content: ":"; -} - -.guionai-web__tool-details dd { - margin: 0; - color: var(--dsw-alias-label-secondary); -} - -.guionai-web__tool-body { - display: grid; - gap: 8px; -} - -.guionai-web__source-link, -.guionai-web__link-list a { - color: var(--dsw-alias-label-link); - text-decoration: none; -} - -.guionai-web__source-link:hover, -.guionai-web__link-list a:hover { - text-decoration: underline; -} - -.guionai-web__source-link:focus-visible, -.guionai-web__link-list a:focus-visible, -.guionai-web__raw-output summary:focus-visible { - outline: 2px solid var(--dsw-alias-interactive-border-focus); - outline-offset: 2px; -} - -.guionai-web__link-list, -.guionai-web__library-list { - display: grid; - gap: 6px; - margin: 0; - padding-left: 18px; - color: var(--dsw-alias-label-secondary); - font-size: 13px; - line-height: 20px; -} - -.guionai-web__link-list a { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.guionai-web__library-list li { - display: flex; - gap: 8px; - min-width: 0; -} - -.guionai-web__library-list code { - flex: none; - color: var(--dsw-alias-label-primary); - font: var(--dsw-font-markdown-code-block-small); -} - -.guionai-web__library-list span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.guionai-web__tool-error { - color: var(--dsw-alias-state-error-primary); -} - -.guionai-web__raw-output { - color: var(--dsw-alias-label-secondary); - font-size: 13px; - line-height: 20px; -} - -.guionai-web__raw-output summary { - width: fit-content; - cursor: pointer; -} - -.guionai-web__raw-output pre { - max-height: 320px; - margin: 8px 0 0; - padding: 10px; - overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - color: var(--dsw-alias-label-secondary); - font: var(--dsw-font-markdown-code-block-small); - white-space: pre-wrap; - word-break: break-word; -} diff --git a/packages/dsh-web/src/client.ts b/packages/dsh-web/src/client.ts index 7d00231..9168204 100644 --- a/packages/dsh-web/src/client.ts +++ b/packages/dsh-web/src/client.ts @@ -10,7 +10,7 @@ import type {} from "@deepseek-ai/dsh-client-ui-settings-plugins/client"; import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; import type {} from "@deepseek-ai/dsh-client-ui-slots"; import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client"; -import cssText from "./client.css"; +import { ResearchToolRow, RESEARCH_TOOL_NAMES } from "./tool-row.js"; import { BRAVE_CREDENTIAL_REF, @@ -57,324 +57,6 @@ export interface CredentialStatus { source?: string; writable: boolean; } -function installStyles(css: string): () => void { - const style = document.createElement("style"); - style.dataset.dshPlugin = "guionai-web"; - style.textContent = css; - document.head.append(style); - return () => style.remove(); -} - -type ResearchToolName = "web_search" | "web_fetch" | "web_links" | "web_docs"; - -function WebResearchToolCard({ - toolName, - block, -}: ToolCallViewProps): ReturnType { - const name = toolName as ResearchToolName; - const args = toolArguments(block); - const output = toolOutput(block); - const running = !isToolResult(block); - const error = isToolResult(block) && block.isError; - const title = - name === "web_search" - ? "Search the web" - : name === "web_fetch" - ? "Fetch page" - : name === "web_links" - ? "Find links" - : args.action === "resolve" - ? "Find documentation" - : "Fetch documentation"; - const summary = toolSummary(name, args); - const details = name === "web_fetch" ? fetchDetails(args) : []; - - return createElement( - "section", - { - className: "guionai-web__tool-card", - "aria-label": title, - "data-state": running ? "running" : error ? "error" : "success", - }, - createElement( - "div", - { className: "guionai-web__tool-heading" }, - createElement("span", { - className: "guionai-web__state-dot", - "aria-hidden": true, - }), - createElement("strong", { className: "guionai-web__tool-title" }, title), - createElement( - "span", - { className: "guionai-web__tool-state", role: "status" }, - running ? "Working…" : error ? "Failed" : "Complete", - ), - ), - summary - ? createElement("p", { className: "guionai-web__tool-summary" }, summary) - : null, - details.length > 0 - ? createElement( - "dl", - { className: "guionai-web__tool-details" }, - ...details.map((detail) => - createElement( - "div", - { key: detail.label }, - createElement("dt", null, detail.label), - createElement("dd", null, detail.value), - ), - ), - ) - : null, - running - ? null - : error - ? createElement( - "p", - { className: "guionai-web__tool-error", role: "alert" }, - block.error - ? `${block.error.name}: ${block.error.code}` - : "The request failed.", - ) - : toolBody(name, args, output), - !running && output - ? createElement( - "details", - { className: "guionai-web__raw-output" }, - createElement("summary", null, "Show raw output"), - createElement("pre", null, output), - ) - : null, - ); -} - -function toolBody( - name: ResearchToolName, - args: Record, - output: string, -): ReturnType | null { - if (name === "web_search") { - const count = /^Found (\d+) search results/.exec(output)?.[1]; - return createElement( - "div", - { className: "guionai-web__tool-body" }, - createElement( - "p", - { className: "guionai-web__result-count" }, - count ? `${count} results found` : "Search complete", - ), - createElement( - "p", - { className: "guionai-web__excerpt" }, - excerpt(output), - ), - ); - } - if (name === "web_links") { - const links = linksFromOutput(output); - const count = /^Found (\d+) links?/.exec(output)?.[1]; - return createElement( - "div", - { className: "guionai-web__tool-body" }, - createElement( - "p", - { className: "guionai-web__result-count" }, - count ? `${count} links found` : "Link scan complete", - ), - links.length > 0 - ? createElement( - "ul", - { className: "guionai-web__link-list" }, - ...links.slice(0, 5).map((link) => - createElement( - "li", - { key: link.url }, - createElement( - "a", - { - href: link.url, - target: "_blank", - rel: "noreferrer", - title: link.url, - }, - link.text, - ), - ), - ), - ) - : null, - ); - } - if (name === "web_docs" && args.action === "resolve") { - const libraries = librariesFromOutput(output); - return createElement( - "div", - { className: "guionai-web__tool-body" }, - createElement( - "p", - { className: "guionai-web__result-count" }, - libraries.length > 0 - ? `${libraries.length} library matches` - : "No libraries found", - ), - libraries.length > 0 - ? createElement( - "ul", - { className: "guionai-web__library-list" }, - ...libraries - .slice(0, 5) - .map((library) => - createElement( - "li", - { key: library.id }, - createElement("code", null, library.id), - createElement("span", null, library.title), - ), - ), - ) - : null, - ); - } - const url = typeof args.url === "string" ? args.url : undefined; - return createElement( - "div", - { className: "guionai-web__tool-body" }, - url - ? createElement( - "a", - { - className: "guionai-web__source-link", - href: url, - target: "_blank", - rel: "noreferrer", - }, - "Open source", - ) - : null, - createElement("p", { className: "guionai-web__excerpt" }, excerpt(output)), - ); -} - -function toolArguments( - block: ToolCallViewProps["block"], -): Record { - const argsRaw = isToolResult(block) ? block.call?.argsRaw : block.argsRaw; - if (!argsRaw) return {}; - try { - const value: unknown = JSON.parse(argsRaw); - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; - } catch { - return {}; - } -} - -function toolOutput(block: ToolCallViewProps["block"]): string { - if (!isToolResult(block)) return ""; - return block.content - .map((content: unknown) => - typeof content === "object" && - content !== null && - "type" in content && - content.type === "text" && - "text" in content && - typeof content.text === "string" - ? content.text - : JSON.stringify(content), - ) - .join("\n"); -} - -function toolSummary( - name: ResearchToolName, - args: Record, -): string { - if (name === "web_search") { - const queries = args.queries; - return Array.isArray(queries) && - queries.every((query) => typeof query === "string") - ? queries.join(", ") - : "Web search"; - } - if (name === "web_docs") { - const identifier = args.action === "resolve" ? args.query : args.library_id; - return typeof identifier === "string" - ? identifier - : "Documentation request"; - } - if (typeof args.url !== "string") - return name === "web_links" ? "Page links" : "Web page"; - try { - const url = new URL(args.url); - return `${url.hostname}${url.pathname === "/" ? "" : url.pathname}`; - } catch { - return args.url; - } -} - -export function fetchDetails( - args: Record, -): Array<{ label: string; value: string }> { - const browserRendered = args.render === "browser"; - const details = [ - { - label: "Backend", - value: browserRendered ? "Browser rendered" : "HTTP rendered", - }, - ]; - if (browserRendered && typeof args.waitMs === "number") { - details.push({ label: "Wait", value: formatWait(args.waitMs) }); - } - details.push({ label: "Result", value: fetchResultMode(args) }); - return details; -} - -function formatWait(waitMs: number): string { - return waitMs >= 1_000 && waitMs % 1_000 === 0 - ? `${waitMs / 1_000} s` - : `${waitMs} ms`; -} - -function fetchResultMode(args: Record): string { - if ( - (args.mode === undefined || args.mode === "auto") && - typeof args.section_id === "string" && - args.section_id !== "" - ) - return `Section: ${args.section_id}`; - if (args.mode === "full") return "Full document"; - if (args.mode === "tree") return "Heading tree"; - return "Automatic navigation"; -} - -function excerpt(output: string): string { - const compact = output.replace(/\s+/g, " ").trim(); - return compact.length <= 360 ? compact : `${compact.slice(0, 357)}…`; -} - -function linksFromOutput(output: string): Array<{ text: string; url: string }> { - return [...output.matchAll(/^\d+\. (.+)\n\s*URL: (https?:\/\/\S+)$/gm)].map( - ([, text, url]) => ({ text: text || "(no text)", url: url! }), - ); -} - -function librariesFromOutput( - output: string, -): Array<{ id: string; title: string }> { - return [...output.matchAll(/^- (.+?): (.+)$/gm)].map(([, id, title]) => ({ - id: id!, - title: title!, - })); -} - -function isToolResult( - block: ToolCallViewProps["block"], -): block is Extract { - return "kind" in block && block.kind === "tool-result"; -} - type ClientSettings = Partial; export type GuionSettingsScope = SettingsScope; export type GuionSettingsScopeSpec = SettingsScopeSpec; @@ -784,7 +466,6 @@ function SettingsCard({ } export function apply(ctx: ClientContext): void { - ctx.effect(() => installStyles(cssText), "guionai-web: styles"); const remote = ctx.remote as RemoteApi; const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE, @@ -807,15 +488,17 @@ export function apply(ctx: ClientContext): void { ), ); ctx.slots.inject("tool.call.toolview", function* () { - for (const key of ["web_search", "web_fetch", "web_links", "web_docs"]) { + for (const key of RESEARCH_TOOL_NAMES) { yield ctx.slots.register( { name: "tool.call.toolview", key, + // DSH mounts its native Web rows inside the required tool UI plugin. + priority: -10, inject: () => ({}), } as never, ((props: ToolCallViewProps) => - createElement(WebResearchToolCard, props)) as never, + createElement(ResearchToolRow, props)) as never, ); } }); diff --git a/packages/dsh-web/src/tool-row.module.dshcss b/packages/dsh-web/src/tool-row.module.dshcss new file mode 100644 index 0000000..5cf3b84 --- /dev/null +++ b/packages/dsh-web/src/tool-row.module.dshcss @@ -0,0 +1,119 @@ +.root { + min-width: 0; +} +.row:focus-visible, +.input:focus-visible, +.output:focus-visible, +.inspect:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} +.summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + margin-left: 8px; + font-size: var(--dsh-content-font-size-secondary, 13px); + color: var(--dsw-alias-label-tertiary); +} +.target { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tags { + display: flex; + gap: 4px; + min-width: 0; + max-width: 55%; +} +.tag { + min-width: 0; + max-width: 160px; + padding: 0 4px; + border-radius: 4px; + background: var(--dsw-alias-markdown-code-block); + font-size: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.state { + flex: none; + white-space: nowrap; +} +.state[data-error], +.text[data-error] { + color: var(--dsw-alias-state-error-primary); +} +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} +.details { + margin: 4px 0 4px 4px; +} +.io { + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); + overflow: hidden; +} +.input, +.output { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow: auto; + overscroll-behavior: contain; +} +.output { + max-height: 260px; + border-top: 1px solid var(--dsw-alias-border-l2); +} +.input::-webkit-scrollbar-thumb, +.output::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} +.input::-webkit-scrollbar-track, +.output::-webkit-scrollbar-track { + margin: 6px 0; +} +.label { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} +.text { + min-width: 0; + margin: 0; + font: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); +} +.inspect { + margin-top: 4px; + padding: 0; + border: 0; + background: none; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); + cursor: pointer; +} +.inspect:hover { + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/dsh-web/src/tool-row.ts b/packages/dsh-web/src/tool-row.ts new file mode 100644 index 0000000..29ac5aa --- /dev/null +++ b/packages/dsh-web/src/tool-row.ts @@ -0,0 +1,256 @@ +import { createElement, useState } from "react"; +import { + DisclosureRow, + IconBrowseOutline16, + IconGlobeOutline14, + StateDot, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client"; +import css from "./tool-row.module.dshcss"; + +const TITLES = { + web_search: "Search the web", + web_fetch: "Fetch page", + web_links: "Find links", + web_docs: "Documentation", + web_source_search: "Search source", + web_weather: "Weather", + web_sports: "Sports", + web_finance: "Finance", + web_time: "Time", +} as const; + +export const RESEARCH_TOOL_NAMES = Object.keys(TITLES) as ResearchToolName[]; +type ResearchToolName = keyof typeof TITLES; + +/** + * Non-default request choices shown beside the collapsed research summary. + * @param name - Guion research tool name. + * @param args - Parsed model-supplied arguments, possibly incomplete while streaming. + * @returns Compact labels for explicit choices that differ from defaults. + */ +export function researchParameters( + name: string, + args: Record, +): string[] { + const tags: string[] = []; + const add = (key: string, label: string, fallback?: unknown) => { + const value = args[key]; + if ( + (typeof value === "string" || typeof value === "number") && + value !== "" && + value !== fallback + ) { + tags.push(`${label}: ${value}`); + } + }; + if (name === "web_fetch" || name === "web_links") { + if (args.render === "browser") { + tags.push("browser"); + if (typeof args.waitMs === "number") + tags.push(`wait: ${args.waitMs / 1000} s`); + } + if (name === "web_fetch") { + add("mode", "mode", "auto"); + add("section_id", "section"); + } else add("limit", "limit", 100); + } else if (name === "web_docs") { + add("topic", "topic"); + add("tokens", "tokens", 0); + } else if (name === "web_source_search") { + add("count", "count", 10); + add("context", "context", 10); + add("timeout", "timeout", 0); + } else if (name === "web_weather") { + add("start", "from"); + add("duration", "days"); + } else if (name === "web_sports") { + add("opponent", "opponent"); + add("date_from", "from"); + add("date_to", "to"); + add("num_games", "games"); + add("locale", "locale"); + } else if (name === "web_finance") { + add("type", "type"); + add("market", "market"); + } + return tags; +} + +function summary(name: string, args: Record): string { + if (name === "web_search") + return Array.isArray(args.queries) + ? args.queries + .filter((query): query is string => typeof query === "string") + .join(", ") + : ""; + if (name === "web_docs") + return text(args.action === "resolve" ? args.query : args.library_id); + if (name === "web_source_search") return text(args.query); + if (name === "web_weather") return text(args.location); + if (name === "web_sports") + return [args.league, args.fn, args.team] + .map(text) + .filter(Boolean) + .join(" · "); + if (name === "web_finance") return text(args.ticker); + if (name === "web_time") return text(args.utc_offset); + return text(args.url); +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseArguments(raw: string): Record { + try { + const value: unknown = JSON.parse(raw); + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + } catch { + // Streamed arguments may be incomplete before the call is admitted. + return {}; + } +} + +/** + * Render persisted research inputs and results with native disclosure behavior. + * @param props - Current tool block and optional native inspection action. + * @returns A collapsed research row with scrollable request and result details. + */ +export function ResearchToolRow({ + toolName, + block, + inspect, +}: ToolCallViewProps) { + const [open, setOpen] = useState(false); + const settled = "kind" in block && block.kind === "tool-result"; + const failed = settled && block.isError; + const raw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ""; + const args = parseArguments(raw); + const output = settled + ? block.content + .flatMap((part: unknown) => + typeof part === "object" && + part !== null && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ? [part.text] + : [], + ) + .join("\n") + : ""; + const error = + failed && block.error ? `${block.error.name}: ${block.error.code}` : ""; + const title = + toolName === "web_docs" + ? args.action === "resolve" + ? "Find documentation" + : "Fetch documentation" + : (TITLES[toolName as ResearchToolName] ?? toolName); + const target = summary(toolName, args); + const state = !settled ? "Working…" : failed ? "Failed" : "Complete"; + const tags = researchParameters(toolName, args); + return createElement( + DisclosureRow, + { + title, + icon: + !settled || failed + ? createElement(StateDot, { state: failed ? "error" : "ongoing" }) + : createElement( + toolName === "web_fetch" + ? IconBrowseOutline16 + : IconGlobeOutline14, + { size: 14 }, + ), + open, + expandable: true, + expandOnRowClick: true, + keepContentWhenOpen: true, + onToggle: () => setOpen((value) => !value), + className: css.root, + rowClassName: css.row, + collapsedContent: createElement( + "span", + { className: css.summary }, + createElement("span", { className: css.target, title: target }, target), + createElement( + "span", + { className: css.tags }, + ...tags.map((tag) => + createElement( + "span", + { key: tag, className: css.tag, title: tag }, + tag, + ), + ), + ), + createElement( + "span", + { + className: settled && !failed ? css.srOnly : css.state, + role: "status", + "data-error": failed || undefined, + }, + state, + ), + ), + }, + createElement( + "div", + { className: css.details }, + createElement( + "div", + { className: css.io }, + createElement( + "div", + { + className: css.input, + role: "region", + "aria-label": "Research input", + tabIndex: 0, + }, + createElement("span", { className: css.label }, "IN"), + createElement( + "pre", + { className: css.text }, + Object.keys(args).length + ? JSON.stringify(args, null, 2) + : raw || "Waiting for arguments…", + ), + ), + createElement( + "div", + { + className: css.output, + role: "region", + "aria-label": "Research output", + tabIndex: 0, + }, + createElement("span", { className: css.label }, "OUT"), + createElement( + "pre", + { className: css.text, "data-error": failed || undefined }, + [error, output].filter(Boolean).join("\n") || + (!settled + ? "Working…" + : failed + ? "The request failed." + : "No output."), + ), + ), + ), + inspect + ? createElement( + "button", + { type: "button", className: css.inspect, onClick: inspect }, + "Inspect", + ) + : null, + ), + ); +} diff --git a/packages/dsh-web/test/artifact.test.ts b/packages/dsh-web/test/artifact.test.ts index c3d660e..d022614 100644 --- a/packages/dsh-web/test/artifact.test.ts +++ b/packages/dsh-web/test/artifact.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { SlotCore } from "@deepseek-ai/dsh-client-ui-slots"; import { chmodSync, mkdirSync, @@ -383,14 +384,27 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { }); }, 30_000); - it("loads the packed browser entry through the supported lazy module contract", async () => { + it("loads packed styles and research views alongside the native Web registrations", async () => { const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; const registrations: any[] = []; + const styles: Array<{ + dataset: { pluginCss?: string }; + textContent: string; + }> = []; (globalThis as any).window = { __ModuleLoader__: { load: (registration: unknown) => registrations.push(registration), }, }; + (globalThis as any).document = { + querySelector: (selector: string) => + styles.find((style) => selector.includes(style.dataset.pluginCss!)), + createElement: () => ({ dataset: {}, textContent: "" }), + head: { + appendChild: (style: (typeof styles)[number]) => styles.push(style), + }, + }; try { await import( `${pathToFileURL(join(artifactRoot, "dist", "client.js")).href}?client=1` @@ -398,9 +412,7 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { expect(registrations).toHaveLength(1); expect(registrations[0].id).toBe("@guionai/dsh-web"); const loaded = registrations[0].factory((specifier: string) => { - if (specifier === "@deepseek-ai/dsh-client-ui-primitives") { - return { IconChevronDownOutline14: () => ({}) }; - } + if (specifier === "@deepseek-ai/dsh-client-ui-primitives") return {}; expect(specifier).toBe("react"); return { createElement: () => ({}), @@ -409,16 +421,70 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { useState: (value: T) => [value, () => undefined] as const, }; }); + expect(styles.map((style) => style.dataset.pluginCss).sort()).toEqual([ + "@guionai/dsh-web/settings.module.css", + "@guionai/dsh-web/tool-row.module.css", + ]); + expect(styles.every((style) => style.textContent.length > 0)).toBe(true); expect(loaded.inject).toEqual([ "remote", "remote.credentials", "settingsScope", "slots", ]); - expect(typeof loaded.apply).toBe("function"); + const core = new SlotCore(); + core.register( + { + name: "root", + children: { + "tool.call.toolview": { kind: "keyed", scope: "session" }, + "settings.plugin.item": { kind: "keyed", scope: "root" }, + }, + } as never, + () => null, + ); + for (const key of ["web_search", "web_fetch"]) { + core.register( + { name: "tool.call.toolview", key, registrant: "native" } as never, + () => null, + ); + } + loaded.apply({ + remote: {}, + settingsScope: { bind: () => ({}) }, + slots: { + inject: (_name: string, callback: () => unknown) => { + const effect = callback(); + if ( + effect && + typeof effect === "object" && + Symbol.iterator in effect + ) { + for (const _ of effect as Iterable) { + /* Exhaust registration effects. */ + } + } + }, + register: ( + options: { name: string; key: string; priority?: number }, + component: never, + ) => + core.register( + { ...options, registrant: "guion" } as never, + component, + ), + }, + }); + const selected = core.entriesOfSlot("tool.call.toolview"); + expect(selected).toHaveLength(9); + expect(selected.every((entry) => entry.registrant === "guion")).toBe( + true, + ); } finally { if (previousWindow === undefined) delete (globalThis as any).window; else (globalThis as any).window = previousWindow; + if (previousDocument === undefined) delete (globalThis as any).document; + else (globalThis as any).document = previousDocument; } }); }); diff --git a/packages/dsh-web/test/client.test.ts b/packages/dsh-web/test/client.test.ts index d19535a..e6e4c9a 100644 --- a/packages/dsh-web/test/client.test.ts +++ b/packages/dsh-web/test/client.test.ts @@ -8,10 +8,8 @@ import { SETTINGS_NAMESPACE, } from "../src/contract.js"; import { - apply, decodeSettings, describeCredentialStatus, - fetchDetails, persistKeposBridgeEndpoint, persistProviderSelection, removeCredential, @@ -51,93 +49,6 @@ function fakeApi(overrides: Record = {}) { } describe("DSH settings client credential surface", () => { - it("labels the fetch backend, wait, and retrieval mode from its request", () => { - expect( - fetchDetails({ - render: "browser", - waitMs: 2_000, - mode: "auto", - section_id: "installation", - }), - ).toEqual([ - { label: "Backend", value: "Browser rendered" }, - { label: "Wait", value: "2 s" }, - { label: "Result", value: "Section: installation" }, - ]); - expect(fetchDetails({ section_id: "installation" })).toEqual([ - { label: "Backend", value: "HTTP rendered" }, - { label: "Result", value: "Section: installation" }, - ]); - expect(fetchDetails({})).toEqual([ - { label: "Backend", value: "HTTP rendered" }, - { label: "Result", value: "Automatic navigation" }, - ]); - expect(fetchDetails({ mode: "full" })).toEqual([ - { label: "Backend", value: "HTTP rendered" }, - { label: "Result", value: "Full document" }, - ]); - expect(fetchDetails({ mode: "tree" })).toEqual([ - { label: "Backend", value: "HTTP rendered" }, - { label: "Result", value: "Heading tree" }, - ]); - }); - - it("shadows the host fetch view and registers dedicated views for links and docs", () => { - const registrations: Array<{ key: string; priority?: number }> = []; - const fixture = fakeApi(); - const ctx = { - effect: (_execute: () => () => void) => () => undefined, - remote: { credentials: fixture.credentials, $on: () => () => undefined }, - settingsScope: { - bind: () => ({ - getSnapshot: () => ({ - status: "ready", - writable: true, - value: { provider: "exa" }, - }), - subscribe: () => () => undefined, - set: async () => undefined, - }), - }, - slots: { - inject: (_name: string, callback: () => unknown) => { - const value = callback(); - if ( - value !== null && - typeof value === "object" && - Symbol.iterator in value - ) - for (const _registration of value as Iterable) { - // Exhaust the generator so every keyed registration is observed. - } - }, - register: (spec: { key: string; priority?: number }) => { - if ( - registrations.some( - (registration) => - registration.key === spec.key && - (registration.priority ?? 0) === (spec.priority ?? 0), - ) - ) { - throw new Error(`duplicate keyed slot entry: ${spec.key}`); - } - registrations.push(spec); - return () => undefined; - }, - }, - }; - apply(ctx as any); - expect( - registrations.map(({ key, priority }) => ({ key, priority })), - ).toEqual([ - { key: SETTINGS_NAMESPACE }, - { key: "web_search" }, - { key: "web_fetch" }, - { key: "web_links" }, - { key: "web_docs" }, - ]); - }); - it("persists only the selected provider and drops unknown/secret settings fields", async () => { const calls: Array<{ field: string; value: unknown }> = []; await persistProviderSelection( diff --git a/packages/dsh-web/test/tool-registration.test.ts b/packages/dsh-web/test/tool-registration.test.ts new file mode 100644 index 0000000..5f3c50d --- /dev/null +++ b/packages/dsh-web/test/tool-registration.test.ts @@ -0,0 +1,88 @@ +import { SlotCore } from "@deepseek-ai/dsh-client-ui-slots"; +import { describe, expect, it } from "vitest"; +import { apply } from "../src/client.js"; + +describe("research views alongside native DSH views", () => { + it.each(["native-first", "guion-first"])( + "registers all Guion views with %s loading", + (order) => { + const core = new SlotCore(); + const empty = () => null; + core.register( + { + name: "root", + children: { + "tool.call.toolview": { kind: "keyed", scope: "session" }, + "settings.plugin.item": { kind: "keyed", scope: "root" }, + }, + } as never, + empty, + ); + const native = () => { + for (const key of ["web_search", "web_fetch"]) { + core.register( + { + name: "tool.call.toolview", + key, + registrant: "web-toolview", + } as never, + empty, + ); + } + }; + const guion = () => + apply({ + effect: () => () => undefined, + remote: {}, + settingsScope: { bind: () => ({}) }, + slots: { + inject: (_name: string, callback: () => unknown) => { + const effect = callback(); + if ( + effect && + typeof effect === "object" && + Symbol.iterator in effect + ) { + for (const _ of effect as Iterable) { + /* Run every registration effect. */ + } + } + }, + register: ( + options: { name: string; key: string; priority?: number }, + component: Parameters[1], + ) => + core.register( + { ...options, registrant: "guionai-dsh-web" } as never, + component, + ), + }, + } as never); + if (order === "native-first") { + native(); + guion(); + } else { + guion(); + native(); + } + const entries = core.entries("tool.call.toolview"); + for (const key of [ + "web_search", + "web_fetch", + "web_links", + "web_docs", + "web_source_search", + "web_weather", + "web_sports", + "web_finance", + "web_time", + ]) { + const selected = entries.find((entry) => entry.options.key === key); + expect(selected?.registrant).toBe("guionai-dsh-web"); + } + expect( + entries.filter((entry) => entry.registrant === "web-toolview"), + ).toHaveLength(2); + }, + ); +}); diff --git a/packages/dsh-web/test/tool-row.test.ts b/packages/dsh-web/test/tool-row.test.ts new file mode 100644 index 0000000..389f6a3 --- /dev/null +++ b/packages/dsh-web/test/tool-row.test.ts @@ -0,0 +1,121 @@ +import { createElement } from "react"; +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_LINK_LIMIT } from "@guionai/web-core"; +import type { ToolCallViewProps } from "@deepseek-ai/dsh-client-ui-tool/client"; +import { ResearchToolRow, researchParameters } from "../src/tool-row.js"; + +const require = createRequire(import.meta.url); +const renderToStaticMarkup = require("react-dom/server") + .renderToStaticMarkup as (element: unknown) => string; +function settled( + name: string, + args: object, + output = "Returned research text", +): ToolCallViewProps { + return { + toolName: name, + block: { + kind: "tool-result", + callId: "call-1", + name, + call: { name, argsRaw: JSON.stringify(args) }, + content: [{ type: "text", text: output }], + isError: false, + }, + } as ToolCallViewProps; +} + +describe("compact research presentation", () => { + it("keeps default page choices out of the row", () => { + expect( + researchParameters("web_fetch", { render: "http", mode: "auto" }), + ).toEqual([]); + expect( + researchParameters("web_links", { limit: DEFAULT_LINK_LIMIT }), + ).toEqual([]); + expect(researchParameters("web_docs", { tokens: 0 })).toEqual([]); + expect( + researchParameters("web_source_search", { + count: 10, + context: 10, + timeout: 0, + }), + ).toEqual([]); + }); + it("shows browser waits and navigation choices without provider configuration", () => { + expect( + researchParameters("web_fetch", { + render: "browser", + waitMs: 0, + section_id: "install", + mode: "auto", + }), + ).toEqual(["browser", "wait: 0 s", "section: install"]); + expect(researchParameters("web_fetch", { mode: "tree" })).toEqual([ + "mode: tree", + ]); + expect( + researchParameters("web_links", { + render: "browser", + waitMs: 250, + limit: 5, + }), + ).toEqual(["browser", "wait: 0.25 s", "limit: 5"]); + expect( + researchParameters("web_docs", { topic: "routing", tokens: 2000 }), + ).toEqual(["topic: routing", "tokens: 2000"]); + }); + it("renders a collapsed native row without mounting a long result", () => { + const html = renderToStaticMarkup( + createElement( + ResearchToolRow, + settled( + "web_fetch", + { url: "https://example.test/docs", mode: "tree" }, + "RESULT".repeat(10000), + ), + ), + ); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('role="button"'); + expect(html).toContain("https://example.test/docs"); + expect(html).toContain("mode: tree"); + expect(html).not.toContain("RESULT"); + }); + it.each([ + ["web_search", { queries: ["one", "two"] }, "one, two"], + ["web_docs", { action: "resolve", query: "React" }, "Find documentation"], + ["web_source_search", { query: "repo:test symbol" }, "repo:test symbol"], + ["web_weather", { location: "Taipei" }, "Taipei"], + [ + "web_sports", + { league: "nba", fn: "schedule", team: "BOS" }, + "nba · schedule · BOS", + ], + ["web_finance", { ticker: "MSFT" }, "MSFT"], + ["web_time", { utc_offset: "+08:00" }, "+08:00"], + ])("shows the %s request target", (name, args, expected) => { + const html = renderToStaticMarkup( + createElement(ResearchToolRow, settled(name, args)), + ); + expect(html).toContain(expected); + }); + it("distinguishes failed and running calls without requiring complete arguments", () => { + const failed = settled("web_fetch", { url: "https://example.test" }); + failed.block = { + ...failed.block, + isError: true, + } as ToolCallViewProps["block"]; + expect( + renderToStaticMarkup(createElement(ResearchToolRow, failed)), + ).toContain("Failed"); + const running = { + toolName: "web_fetch", + block: { callId: "call-1", name: "web_fetch", argsRaw: '{"url":' }, + } as ToolCallViewProps; + expect( + renderToStaticMarkup(createElement(ResearchToolRow, running)), + ).toContain("Working…"); + }); +}); diff --git a/packages/dsh-web/tsup.config.ts b/packages/dsh-web/tsup.config.ts index 800e26e..8e0fa2c 100644 --- a/packages/dsh-web/tsup.config.ts +++ b/packages/dsh-web/tsup.config.ts @@ -1,3 +1,4 @@ +import { basename } from "node:path"; import { defineConfig } from "tsup"; import type { Plugin as EsbuildPlugin } from "esbuild"; import { compileCssModule } from "./scripts/css-modules.js"; @@ -25,7 +26,7 @@ function cssModulesPlugin(): EsbuildPlugin { setup(build) { build.onLoad({ filter: /\.module\.dshcss$/ }, async (args) => { const { css, classes } = await compileCssModule(args.path); - const styleId = "@guionai/dsh-web/settings.module.css"; + const styleId = `@guionai/dsh-web/${basename(args.path).replace(".dshcss", ".css")}`; return { loader: "js", contents: [