diff --git a/CHANGELOG.md b/CHANGELOG.md index 89dc4917e..19bb71011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - **Cluster members use this machine's provider logins.** A member always runs its sessions with this machine's provider logins, whatever its own **Use this server's provider logins** setting says; Settings shows that toggle on and disabled. It is computed from membership, so leaving brings the host's own setting back. If `pools.json` cannot be read, session starts on a host are refused rather than started with other logins. Cluster placement skips a member whose daemon cannot take these logins (below link protocol 9, or a link that does not share) before preparing a checkout there. **Before upgrading:** after this update, every linked host already in the first pool uses this machine's provider logins (it can read their access tokens and stored API keys) without a confirmation having been shown. This includes a first pool disabled through the API, and a host added to the first pool later through `PUT /api/hosts` or by editing `pools.json`. Each linked member needs an updated ompweb host daemon (link protocol 9) and omp 15.13.1 or newer; until its daemon is updated, cluster placement skips that member, and a start fails when no other member is eligible. Before updating, remove a host that should not receive the logins from the first pool using `PUT /api/hosts` or editing `~/.omp/agent/omp-web/pools.json`; the old version has no membership switch. - **Hosts can use the server's provider logins.** **Settings → Hosts** has a per-host toggle, **Use this server's provider logins**, off by default; turning it on asks for confirmation. Sessions started on that host then use the logins stored on the ompweb server instead of the host's own, through `omp auth-broker serve` on the server and a loopback forwarder in the host daemon. The host token never enters an omp process's environment, and OAuth refresh tokens never leave the server machine; the host can read current access tokens and stored API keys. Starting a session on a sharing host needs a daemon on link protocol v9 and omp 15.13.1 or newer, and is refused otherwise rather than falling back to the host's own logins. Custom providers in `models.yml`, environment-variable API keys and omp settings are not shared. A reverse proxy must let `/api/hosts/broker/` through without its own authentication, response buffering or redirects. +- **Answer the agent's complete form in one panel.** From the omp release that includes [can1357/oh-my-pi#18646](https://github.com/can1357/oh-my-pi/pull/18646), `ask` opens its HTML form in omp-web with native controls, validation and Other answers. Forms support cancellation, timeout defaults and reconnect replay on local and linked hosts. Existing extension select/input/editor dialogs remain unchanged. - **Windows hosts can be pool workers and accept local maintenance controls.** `ompweb host status`, `drain` and `resume` now work on Windows through a named pipe with a random name and a per-start token, kept in `/omp-web/host-control.json`; requests without the current token are refused. Windows hosts also report CPU busy time and available memory, so pools can place sessions on them. Windows reports no load average, so CPU pressure there comes from busy time alone. - **Archive and restore sessions on linked hosts.** Archiving now works for sessions on any linked machine, not only this one. The Archived Sessions browser lists every host's archive, shows which host each session is on, and restores it on that host. A host that is offline or runs an older ompweb host is named in the browser instead of hiding its archive silently. Restore rejects a session ID already active on any registered host, so every registered host must be online to restore, even an archive on this machine; the browser names the offline host that blocks it. A per-session claim stops web-server processes that share one agent directory from restoring the same session together; it does not coordinate separate web servers that share a daemon. A restore on another host keeps running when the web server stops waiting for it (after 30 s, or when the link drops), so the session stays claimed until that host's daemon process confirms it stopped: restoring the same ID again, on any host, is refused until then instead of creating a second active copy. Only the daemon process that took the restore can confirm that, so if the daemon was replaced, restarted or shut down meanwhile, or its host was unregistered, the session stays blocked: there is no timeout and no automatic clearing, and recovery is manual (see AGENTS.md). A restored session's attachments and other files are put back before the session becomes visible, so a file attached from another tab at that moment can no longer fail the restore or block retrying it. Restore streams archives with fixed 256 MiB compressed and expanded limits and rejects unsupported session IDs before consuming the archive. - **Traditional Chinese (`zh-TW`) interface language.** Select Traditional Chinese (繁體中文) in the top-bar language menu. On compact layouts, open **More controls** first. Alternatively, let omp-web pick it automatically when the browser language is `zh-TW`, `zh-HK`, `zh-MO`, or `zh-Hant`. Localized Web Push notifications and a Traditional Chinese README (`README.zh-TW.md`) are included. diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 660a64817..5edd7c1ce 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -1000,7 +1000,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionHostId, newSessio return null; }, [bashRunning, pendingBash, isCompacting, agentRunning, streamState.streamingMessage, pendingToolHeaders.length, agentPhase, activeSubagentCount, currentTodoPhase, t, tn]); - const askOpen = extensionDialog?.method === "ask"; + const askOpen = extensionDialog?.method === "ask" || extensionDialog?.method === "form"; const hiddenComposerRef = useRef(null); useEffect(() => { // A hidden composer that keeps focus keeps the phone keyboard up: the diff --git a/components/ExtensionDialog.form.test.mjs b/components/ExtensionDialog.form.test.mjs new file mode 100644 index 000000000..2671900ce --- /dev/null +++ b/components/ExtensionDialog.form.test.mjs @@ -0,0 +1,55 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import React from "react"; +const { act, cleanup, fireEvent, render, screen, waitFor } = await import("@testing-library/react/pure.js"); +const { ExtensionDialog } = await import("./ExtensionDialog.tsx"); +afterEach(cleanup); + +function mount(extra = {}, attached = true) { + const responses = []; + const request = { type: "extension_ui_request", id: "whole-form", method: "form", html: '
', ...extra }; + const view = render(React.createElement(ExtensionDialog, { request, attached, onRespond: (req, response) => responses.push({ id: req.id, ...response }) })); + return { ...view, responses, request }; +} + +for (const attached of [true, false]) { + test(`whole form submits data without opt-in state (${attached ? "attached" : "overlay"})`, () => { + const { responses } = mount({}, attached); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses.map(response => ({ ...response, data: { ...response.data } })), [{ id: "whole-form", data: { name: "Ada" } }]); + }); +} + +test("form Cancel does not submit defaults", () => { + const { responses } = mount(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + assert.deepEqual(responses, [{ id: "whole-form", cancelled: true }]); +}); + +test("expired replay answers timedOut exactly once, retaining absolute deadline across replay", async () => { + const { responses, request, rerender } = mount({ expiresAt: Date.now() - 1, timeout: 60_000 }); + await waitFor(() => assert.deepEqual(responses, [{ id: "whole-form", cancelled: true, timedOut: true }])); + rerender(React.createElement(ExtensionDialog, { request: { ...request }, attached: true, onRespond: (req, response) => responses.push({ id: req.id, ...response }) })); + assert.equal(responses.length, 1); +}); + +test("changing request cancels the previous timeout", async () => { + const { responses, rerender } = mount({ timeout: 20 }); + rerender(React.createElement(ExtensionDialog, { request: { type: "extension_ui_request", id: "new-form", method: "form", html: '' }, attached: true, onRespond: (req, response) => responses.push({ id: req.id, ...response }) })); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)); }); + assert.deepEqual(responses, []); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + assert.deepEqual(responses.map(response => ({ ...response, data: { ...response.data } })), [{ id: "new-form", data: { text: "new" } }]); +}); + +test("a refused form reply keeps the values available for retry", async () => { + let attempts = 0; + const request = { type: "extension_ui_request", id: "retry-form", method: "form", html: '' }; + render(React.createElement(ExtensionDialog, { request, attached: true, onRespond: async () => { attempts++; return false; } })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Submit" })); }); + assert.equal(attempts, 1); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Submit" })); }); + assert.equal(attempts, 2); + assert.equal(screen.getByRole("textbox").value, "keep"); +}); diff --git a/components/ExtensionDialog.tsx b/components/ExtensionDialog.tsx index 61145756a..d01563262 100644 --- a/components/ExtensionDialog.tsx +++ b/components/ExtensionDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from "react"; +import { useEffect, useId, useRef, useState, useSyncExternalStore, type RefObject } from "react"; import { Eye, EyeOff } from "lucide-react"; import type { ExtensionUiRequest } from "@/lib/types"; import type { RpcAskDialogAnswer } from "@/lib/pi-types"; @@ -8,17 +8,19 @@ import { useI18n } from "@/lib/i18n"; import { useModalDialog } from "@/hooks/useModalDialog"; import { useTouchTargets } from "@/hooks/useTouchTargets"; import { matchMobileViewport } from "@/hooks/useIsMobile"; +import { FormDialog } from "./FormDialog"; export type ExtensionDialogRequest = Extract< ExtensionUiRequest, - { method: "select" | "confirm" | "input" | "editor" | "ask" } + { method: "select" | "confirm" | "input" | "editor" | "ask" | "form" } >; export type ExtensionDialogResponse = | { value: string } | { confirmed: boolean } - | { cancelled: true } - | { answers: RpcAskDialogAnswer[] }; + | { cancelled: true; timedOut?: boolean } + | { answers: RpcAskDialogAnswer[] } + | { data: Record }; // The Other text lives as long as the question: choosing a regular option only // unchooses it, so the text is still in its row when Other is chosen again. @@ -241,6 +243,9 @@ export function ExtensionDialog({ attached?: boolean; }) { const { t } = useI18n(); + const formId = useId(); + const answeredFormRef = useRef(null); + const formDeadlineRef = useRef<{ id: string; at: number } | null>(null); const [value, setValue] = useState(request.method === "editor" ? request.prefill ?? "" : ""); const [showPassword, setShowPassword] = useState(false); const [selectedOption, setSelectedOption] = useState(null); @@ -277,7 +282,7 @@ export function ExtensionDialog({ return question.multi || selected.length > 0 || (otherChosen && other.trim() !== ""); }) : request.method !== "select" || selectedOption !== null; - const title = request.method === "ask" ? t("chatWindow.askTitle") : request.title; + const title = request.method === "ask" || request.method === "form" ? t("chatWindow.askTitle") : request.title; // Answering removes the panel. Release its text field first, in the same // task, so the phone keyboard closes once instead of flashing as the @@ -287,11 +292,18 @@ export function ExtensionDialog({ if (awaitingNext) return; setAnsweredId(request.id); } + if (request.method === "form") { + if (answeredFormRef.current === request.id) return; + answeredFormRef.current = request.id; + } const active = document.activeElement; if (active instanceof HTMLElement && panelRef.current?.contains(active)) active.blur(); const id = request.id; void Promise.resolve(onRespond(request, response)).then((sent) => { - if (sent === false) setAnsweredId((current) => current === id ? null : current); + if (sent === false) { + setAnsweredId((current) => current === id ? null : current); + if (answeredFormRef.current === id) answeredFormRef.current = null; + } }); }; const cancel = () => respond({ cancelled: true }); @@ -333,6 +345,26 @@ export function ExtensionDialog({ return () => window.cancelAnimationFrame(frame); }, [attached, panelRef, request.id]); + useEffect(() => { + if (request.method !== "form") return; + if (formDeadlineRef.current?.id !== request.id) { + formDeadlineRef.current = { + id: request.id, + at: request.expiresAt ?? (request.timeout && request.timeout > 0 ? Date.now() + request.timeout : Infinity), + }; + } + const deadline = formDeadlineRef.current.at; + if (!Number.isFinite(deadline)) return; + const timer = setTimeout(() => { + if (answeredFormRef.current === request.id) return; + answeredFormRef.current = request.id; + const active = document.activeElement; + if (active instanceof HTMLElement && panelRef.current?.contains(active)) active.blur(); + onRespond(request, { cancelled: true, timedOut: true }); + }, Math.max(0, deadline - Date.now())); + return () => clearTimeout(timer); + }, [request, onRespond, panelRef]); + const submitValue = () => { if (request.method === "confirm") { respond({ confirmed: true }); @@ -422,6 +454,9 @@ export function ExtensionDialog({
+ {request.method === "form" && ( + respond({ data })} /> + )} {request.method === "confirm" && (
{request.message}
)} @@ -748,7 +783,9 @@ export function ExtensionDialog({ ) : request.method !== "select" ? ( + + + `); + assert.equal(container.querySelectorAll("form").length, 1); + assert.equal(container.querySelector("script,style,iframe,button[formaction],input[type=image]"), null); + assert.equal(container.querySelector("[onclick],[onerror],[onsubmit],[style],[autofocus],[formaction],[action]"), null); + assert.equal(container.querySelector('a[href]'), null); + assert.equal(container.querySelector('img[src^="javascript:"],img[src^="data:text"]'), null); + assert.equal(container.querySelector('img[alt="safe"]').getAttribute("src"), "https://example.test/safe.png"); + assert.equal(form.querySelector('[name="ok"]').form, form); + assert.equal(document.activeElement, document.body); + submit(); + assert.deepEqual({ ...responses[0] }, { ok: "yes", nested: "kept", reportValidity: "safe", elements: "safe" }); +}); + +test("required radio groups allow a complete Other answer but reject no choice", () => { + const { container, form, submit, responses } = mount(` + `); + assert.equal(form.checkValidity(), false); + submit(); + assert.equal(responses.length, 0); + fireEvent.click(container.querySelector("[data-other-choice]")); + assert.equal(form.checkValidity(), false); + fireEvent.input(container.querySelector("[data-other-text]"), { target: { value: "custom" } }); + assert.equal(form.checkValidity(), true); + submit(); + assert.deepEqual({ ...responses[0] }, { plan: "custom" }); +}); + +test("disabled options retain group cardinality without becoming successful values", () => { + const { submit, responses } = mount(` + + `); + submit(); + assert.deepEqual({ ...responses[0] }, { checks: [], many: ["b"] }); +}); + +test("Other's native FormData marker cannot collide with a real option value", () => { + const { container, submit, responses } = mount(` + `); + fireEvent.click(container.querySelector("[data-other-choice]")); + fireEvent.input(container.querySelector("[data-other-text]"), { target: { value: "custom" } }); + submit(); + assert.deepEqual({ ...responses[0] }, { checks: ["__omp_other__", "custom"] }); +}); diff --git a/components/FormDialog.tsx b/components/FormDialog.tsx new file mode 100644 index 000000000..1a1548dfa --- /dev/null +++ b/components/FormDialog.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { createElement, useEffect, useMemo, useRef, type FormEvent, type ReactNode } from "react"; +import { raw } from "hast-util-raw"; +import { sanitize, type Schema } from "hast-util-sanitize"; +import type { Element, Root, RootContent } from "hast"; +import { useI18n } from "@/lib/i18n"; + +const schema: Schema = { + tagNames: [ + "p", "div", "span", "br", "hr", "strong", "b", "em", "i", "u", "s", "small", "sub", "sup", + "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code", "ul", "ol", "li", "dl", "dt", "dd", + "table", "caption", "thead", "tbody", "tfoot", "tr", "th", "td", "img", "a", + "fieldset", "legend", "label", "input", "select", "optgroup", "option", "textarea", + ], + attributes: { + "*": ["id", "title", "lang", "dir", "ariaLabel"], + label: ["htmlFor"], + input: ["name", "value", "checked", "disabled", "readOnly", "required", "min", "max", "step", + "minLength", "maxLength", "pattern", "placeholder", "size", + ["type", "text", "password", "email", "url", "tel", "search", "number", "range", "date", + "datetime-local", "month", "week", "time", "color", "hidden", "radio", "checkbox"]], + select: ["name", "disabled", "required", "multiple", "size"], + option: ["value", "selected", "disabled", "label"], + optgroup: ["disabled", "label"], + textarea: ["name", "disabled", "readOnly", "required", "minLength", "maxLength", "placeholder", "rows", "cols"], + fieldset: ["disabled"], + img: ["src", "alt", "width", "height"], + th: ["colSpan", "rowSpan", "scope"], + td: ["colSpan", "rowSpan"], + ol: ["start", "reversed"], + li: ["value"], + }, + protocols: { src: ["http", "https", "blob", "data"] }, + // Override the sanitizer's markdown defaults, which disable every input. + required: {}, + // Names are data keys, never property access; IDs are scoped when rendered. + clobber: [], + strip: ["script", "style", "iframe", "object", "embed", "template", "svg", "math"], +}; + +type Group = { name: string; type: "radio" | "checkbox"; options: Element[]; required: boolean; otherValue: string }; +type Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +function elements(node: Root | Element): Element[] { + return node.children.flatMap((child) => child.type === "element" ? [child, ...elements(child)] : []); +} + +function safeImage(src: string): boolean { + if (/^data:/i.test(src)) return /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,/i.test(src); + try { + return ["http:", "https:", "blob:"].includes(new URL(src, "https://omp.invalid/").protocol); + } catch { + return false; + } +} + +export function FormDialog({ html, onSubmit, formId }: { + html: string; + onSubmit: (data: Record) => void; + formId: string; +}) { + const { t } = useI18n(); + const content = useRef(null); + const { tree, groups } = useMemo(() => { + // Parse inertly, sanitize the tree, then render only sanitized nodes through React. + const source = { type: "root", children: [{ type: "raw", value: html }] } as Parameters[0]; + const tree = sanitize(raw(source), schema) as Root; + const byName = new Map(); + for (const node of elements(tree)) { + const { name, type } = node.properties; + if (node.tagName !== "input" || !name || (type !== "radio" && type !== "checkbox")) continue; + const key = `${type}:${name}`; + let group = byName.get(key); + if (!group) { + group = { name: String(name), type, options: [], required: false, otherValue: "__omp_other__" }; + byName.set(key, group); + } + group.options.push(node); + group.required ||= !!node.properties.required; + } + for (const group of byName.values()) { + while (group.options.some((option) => String(option.properties.value ?? "on") === group.otherValue)) group.otherValue += "_"; + } + return { tree, groups: [...byName.values()].filter((group) => group.options.length >= 2) }; + }, [html]); + + function controls(): Control[] { + return Array.from(content.current?.querySelectorAll("input,select,textarea") ?? []); + } + + function updateConstraints() { + const all = controls(); + groups.forEach((group, index) => { + const choices = all.filter((control): control is HTMLInputElement => control.tagName === "INPUT" + && control.name === group.name && control.type === group.type && !control.matches(":disabled")); + const other = content.current?.querySelector(`[data-other-choice="${index}"]`); + const text = content.current?.querySelector(`[data-other-text="${index}"]`); + if (!other || !text) return; + const originals = all.filter((control) => control.tagName === "INPUT" && control.name === group.name + && control.type === group.type && control !== other); + other.disabled = !originals.some((control) => !control.matches(":disabled")); + text.disabled = other.disabled || !other.checked; + text.required = !text.disabled; + const value = text.value.trim(); + text.setCustomValidity(!text.disabled && !value ? t("chatWindow.otherPlaceholder") + : !text.disabled && group.options.some((option) => String(option.properties.value ?? "on") === value) + ? t("chatWindow.formOtherUnlisted") : ""); + if (group.type === "checkbox") { + choices.forEach((choice) => { choice.required = false; }); + // Native checkbox validation, but any selected member satisfies the group. + const required = group.required && group.options.some((option, optionIndex) => + option.properties.required && !originals[optionIndex]?.matches(":disabled")); + if (required && choices.length && !choices.some((choice) => choice.checked)) choices[0].required = true; + } + }); + } + + useEffect(() => { + updateConstraints(); + }); + + function renderNode(node: RootContent, key: number): ReactNode { + if (node.type === "text") return node.value; + if (node.type !== "element") return null; + const props: Record = { ...node.properties, key }; + if (props.id) props.id = `${formId}-${props.id}`; + if (props.htmlFor) props.htmlFor = `${formId}-${props.htmlFor}`; + if (props.ariaLabel) { + props["aria-label"] = props.ariaLabel; + delete props.ariaLabel; + } + if (node.tagName === "img" && (!props.src || !safeImage(String(props.src)))) return null; + if (node.tagName === "input") { + // An unsupported input type must not become a submit/image/file control. + props.type ??= "text"; + if ("value" in props) { + props.defaultValue = props.value; + delete props.value; + } + if ("checked" in props) { + props.defaultChecked = props.checked; + delete props.checked; + } + return createElement("input", props); + } + if (node.tagName === "select") { + const selected = elements(node).filter((option) => option.tagName === "option" && option.properties.selected); + if (selected.length) { + const values = selected.map((option) => String(option.properties.value ?? option.children.map((child) => child.type === "text" ? child.value : "").join(""))); + props.defaultValue = props.multiple ? values : values[values.length - 1]; + } + } + if (node.tagName === "option") delete props.selected; + if (node.tagName === "textarea") { + props.defaultValue = node.children.map((child) => child.type === "text" ? child.value : "").join(""); + return createElement("textarea", props); + } + if (["img", "br", "hr"].includes(node.tagName)) return createElement(node.tagName, props); + return createElement(node.tagName, props, node.children.map(renderNode)); + } + + function submit(event: FormEvent) { + event.preventDefault(); + event.stopPropagation(); + updateConstraints(); + // Named controls can shadow form methods (e.g. name="reportValidity"). + const form = event.currentTarget; + if (!form.ownerDocument.defaultView!.HTMLFormElement.prototype.reportValidity.call(form)) return; + const data: Record = Object.create(null); + const nativeData = new form.ownerDocument.defaultView!.FormData(form); + const all = controls().filter((control) => control.name); + const byName = new Map(); + for (const control of all) { + if (control.hasAttribute("data-other-choice") || control.hasAttribute("data-other-text")) continue; + const fields = byName.get(control.name) ?? []; + fields.push(control); + byName.set(control.name, fields); + } + for (const [name, fields] of byName) { + const field = fields.find((control) => !control.matches(":disabled")); + if (!field) continue; + const values = [...new Set(nativeData.getAll(name).filter((value): value is string => typeof value === "string"))]; + if (field.tagName === "SELECT") { + const select = field as HTMLSelectElement; + const enabledValues = new Set(Array.from(select.options) + .filter((option) => option.selected && !option.disabled && !option.parentElement?.matches("optgroup:disabled")) + .map((option) => option.value)); + const selected = values.filter((value) => enabledValues.has(value)); + if (select.multiple && select.options.length >= 2) data[name] = selected; + else if (selected.length) data[name] = selected[0]; + } else if (field.type === "radio" || field.type === "checkbox") { + const groupIndex = groups.findIndex((group) => group.name === name && group.type === field.type); + if (groupIndex >= 0) { + const markerIndex = values.indexOf(groups[groupIndex].otherValue); + if (markerIndex >= 0) values.splice(markerIndex, 1); + const other = content.current?.querySelector(`[data-other-choice="${groupIndex}"]`); + const text = content.current?.querySelector(`[data-other-text="${groupIndex}"]`); + if (other?.checked && !other.disabled && text?.value.trim()) values.push(text.value.trim()); + } + const isMulti = field.type === "checkbox" && groups.some((group) => group.name === name && group.type === "checkbox"); + if (isMulti) data[name] = [...new Set(values)]; + else if (values.length) data[name] = values[0]; + } else if (field.type === "number" || field.type === "range") { + if (values[0] !== undefined && values[0] !== "") data[name] = Number(values[0]); + } else { + data[name] = values[0] ?? ""; + } + } + onSubmit(data); + } + + return ( +
+
+ {tree.children.map(renderNode)} + {groups.map((group, index) => ( +
+ {group.name} + + +
+ ))} +
+
+ ); +} diff --git a/hooks/useAgentSession-stream.ts b/hooks/useAgentSession-stream.ts index 3bbdeceab..7a62df09a 100644 --- a/hooks/useAgentSession-stream.ts +++ b/hooks/useAgentSession-stream.ts @@ -219,7 +219,7 @@ export function toThinkingModelMeta(model: { provider?: string; id?: string; nam return { provider: model.provider, modelId: model.id, name: model.name, reasoning: model.reasoning, thinking: model.thinking }; } -export type ExtensionUiDialogRequest = Extract; +export type ExtensionUiDialogRequest = Extract; export type ExtensionUiCustomRequest = Extract; // omp's rpc-ui frames add open_url (OAuth) and cancel on top of lib/types' union. export type IncomingExtensionUiRequest = diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 1f306ee3d..19824b2d2 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1970,7 +1970,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const respondToExtensionUi = useCallback(async ( request: ExtensionUiDialogRequest, - response: { value: string } | { confirmed: boolean } | { cancelled: true } | { answers: RpcAskDialogAnswer[] }, + response: { value: string } | { confirmed: boolean } | { cancelled: true; timedOut?: boolean } | { answers: RpcAskDialogAnswer[] } | { data: Record }, ): Promise => { const sid = sessionIdRef.current; if (!sid) { @@ -2043,6 +2043,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { case "input": case "editor": case "ask": + case "form": if (extensionDialogClearTimerRef.current) { clearTimeout(extensionDialogClearTimerRef.current); extensionDialogClearTimerRef.current = null; diff --git a/lib/hosts/daemon-channels.test.mjs b/lib/hosts/daemon-channels.test.mjs index 85cbdf170..8abac96bb 100644 --- a/lib/hosts/daemon-channels.test.mjs +++ b/lib/hosts/daemon-channels.test.mjs @@ -111,18 +111,21 @@ test("advisor changes never replace a peer command, unsettled background work, d } }); -test("join captures full stream and dialogs before later deltas; first answer closes the other UI",async()=>{ +for (const method of ["ask", "form"]) { +test(`join captures full stream and ${method} dialogs before later deltas; first answer closes the other UI`,async()=>{ const h=harness(), a=await h.table.open(h.a,{sessionId:SAVED,launch});h.table.release(h.a,a.ch); const child=h.children[0];child.emit({type:"message_start",messageId:"m",message:{role:"assistant",content:[{type:"text",text:"one"}]}}); - child.emit({type:"extension_ui_request",id:"q",method:"ask"}); + child.emit({type:"extension_ui_request",id:"q",method,...(method === "form" ? { html: '
' } : { questions: [] })}); const b=await h.table.open(h.b,{sessionId:SAVED,launch});assert.equal(JSON.parse(b.streaming).message.content[0].text,"one");assert.equal(JSON.parse(b.pending[0]).id,"q"); child.emit({type:"message_update",messageId:"m",message:{role:"assistant"},assistantMessageEvent:{type:"text_delta",contentIndex:0,delta:"two"}}); assert.equal(h.output.filter(x=>x.id==="l2").length,0);h.table.release(h.b,b.ch); assert.equal(h.output.filter(x=>x.id==="l2").length,1); - h.table.stdin(h.b,b.ch,JSON.stringify({type:"extension_ui_response",id:"q"}));h.table.stdin(h.a,a.ch,JSON.stringify({type:"extension_ui_response",id:"q"})); + const answer = {type:"extension_ui_response",id:"q",...(method === "form" ? { data: { answer: "yes" } } : { answers: [] })}; + h.table.stdin(h.b,b.ch,JSON.stringify(answer));h.table.stdin(h.a,a.ch,JSON.stringify(answer)); assert.equal(child.writes.filter(c=>c.type==="extension_ui_response").length,1); assert.ok(h.output.some(x=>x.id==="l1"&&JSON.parse(x.body.line).targetId==="q"));await h.table.stop(); }); +} test("a chunked private reply is never broadcast and session moves retain aliases",async()=>{ const h=harness(),a=await h.table.open(h.a,{sessionId:SAVED,launch});h.table.release(h.a,a.ch); @@ -150,7 +153,7 @@ test("startup negotiation precedes global settings and runs only once for two me const a = await h.table.open(h.a, { sessionId: SAVED, launch }); await h.table.open(h.b, { sessionId: SAVED, launch }); assert.deepEqual(h.children[0].writes.map(command => command.type), - ["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog", "get_state"]); + ["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog", "set_form_dialog", "get_state"]); assert.equal(h.children[0].writes[1].messageUpdates, "delta"); assert.deepEqual(h.output, []); h.table.release(h.a, a.ch); await h.table.stop(); diff --git a/lib/hosts/daemon-channels.ts b/lib/hosts/daemon-channels.ts index 52376436c..2c50e39ce 100644 --- a/lib/hosts/daemon-channels.ts +++ b/lib/hosts/daemon-channels.ts @@ -328,6 +328,7 @@ export class ChannelTable { catch { if (delta) await this.command(channel, { type: "set_event_filter", events: null, messageUpdates: "full" }).catch(() => {}); } await this.command(channel, { type: "set_subagent_subscription", level: "events" }).catch(() => {}); await this.command(channel, { type: "set_ask_dialog", enabled: true }).catch(() => {}); + await this.command(channel, { type: "set_form_dialog", enabled: true }).catch(() => {}); await this.command(channel, { type: "get_state" }).catch(() => {}); } @@ -496,7 +497,7 @@ export class ChannelTable { return; } if (["branch", "new_session", "switch_session"].includes(frame.type)) channel.sessionMovePending = false; - if (["set_event_filter", "negotiate_protocol", "set_subagent_subscription", "set_ask_dialog", "set_host_tools", "set_host_uri_schemes"].includes(frame.type)) throw invalid("This command is owned by the host daemon"); + if (["set_event_filter", "negotiate_protocol", "set_subagent_subscription", "set_ask_dialog", "set_form_dialog", "set_host_tools", "set_host_uri_schemes"].includes(frame.type)) throw invalid("This command is owned by the host daemon"); if (["extension_ui_response", "host_tool_result", "host_uri_result"].includes(frame.type)) { if (!channel.shared?.answer(link.id, frame)) return; } else if (typeof frame.id === "string") { diff --git a/lib/hosts/hub-v7.test.mjs b/lib/hosts/hub-v7.test.mjs index f70c9027c..d094e381b 100644 --- a/lib/hosts/hub-v7.test.mjs +++ b/lib/hosts/hub-v7.test.mjs @@ -306,7 +306,7 @@ test("v7 joined wrappers skip child-global startup, report interest and preserve await second; detach(); await until(() => messages.filter(m => m.type === "interest").at(-1)?.on === false, "interest released"); - const startup = new Set(["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog"]); + const startup = new Set(["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog", "set_form_dialog"]); assert.deepEqual(messages.filter(m => m.type === "stdin" && startup.has(JSON.parse(m.line).type)), []); await session.destroyAndWait(); assert.equal(messages.some(m => m.type === "kill"), false); @@ -510,7 +510,7 @@ test("explicit v7 reload kills once, restores registrations and leaves startup s assert.deepEqual(messages.filter(message => message.type === "kill").map(message => message.force), [false]); await until(() => messages.filter(message => message.type === "host_tools").length >= 3, "restored tool registration"); assert.deepEqual(messages.filter(message => message.type === "host_tools").at(-1).schemes, [{ scheme: "browser", writable: true }]); - const forbidden = new Set(["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog", "set_host_tools", "set_host_uri_schemes"]); + const forbidden = new Set(["negotiate_protocol", "set_event_filter", "set_subagent_subscription", "set_ask_dialog", "set_form_dialog", "set_host_tools", "set_host_uri_schemes"]); assert.deepEqual(messages.filter(message => message.type === "stdin" && forbidden.has(JSON.parse(message.line).type)), []); }); diff --git a/lib/hosts/pending-requests.test.mjs b/lib/hosts/pending-requests.test.mjs index 566324a27..b0cb01529 100644 --- a/lib/hosts/pending-requests.test.mjs +++ b/lib/hosts/pending-requests.test.mjs @@ -15,6 +15,20 @@ test("an ask request answered on stdin is no longer open", () => { assert.deepEqual(pending.lines(), []); }); +test("a form retains its HTML for replay until submission, host dismissal or native cancellation", () => { + const pending = new PendingRequests(LIMIT); + const form = line({ type: "extension_ui_request", id: "form", method: "form", html: '
' }); + for (const response of [{ data: { answer: "yes" } }, { cancelled: true }, { cancelled: true, timedOut: true }]) { + pending.noteStdout(form); + assert.deepEqual(pending.lines(), [form]); + pending.noteStdin(line({ type: "extension_ui_response", id: "form", ...response })); + assert.deepEqual(pending.lines(), []); + } + pending.noteStdout(form); + pending.noteStdout(line({ type: "extension_ui_request", id: "cancel", method: "cancel", targetId: "form" })); + assert.deepEqual(pending.lines(), []); +}); + test("an open host_tool_call keeps its raw line until host_tool_cancel names it", () => { const pending = new PendingRequests(LIMIT); const call = line({ type: "host_tool_call", id: "t1", toolCallId: "c1", toolName: "browser", arguments: {} }); diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 8d7fff06f..e9130b1d7 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -603,6 +603,9 @@ "chatWindow.askOther": "Other: type your own answer", "chatWindow.optionChecked": "checked", "chatWindow.optionUnchecked": "not checked", + "chatWindow.other": "Other", + "chatWindow.otherPlaceholder": "Enter your answer", + "chatWindow.formOtherUnlisted": "Enter an answer not listed above.", "chatWindow.thinking": "Thinking...", "chatWindow.subagentCount.one": "{count} subagent", "chatWindow.subagentCount.other": "{count} subagents", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index cfb622e37..fd5258635 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -586,6 +586,9 @@ "chatWindow.askOther": "その他:自由に回答を入力", "chatWindow.optionChecked": "チェック済み", "chatWindow.optionUnchecked": "未チェック", + "chatWindow.other": "その他", + "chatWindow.otherPlaceholder": "回答を入力してください", + "chatWindow.formOtherUnlisted": "選択肢にない回答を入力してください。", "chatWindow.thinking": "思考中...", "chatWindow.subagentCount.one": "サブエージェント {count} 件", "chatWindow.subagentCount.other": "サブエージェント {count} 件", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index f06efd9c8..7ad34d9d7 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -586,6 +586,9 @@ "chatWindow.askOther": "其他:输入你自己的答案", "chatWindow.optionChecked": "已勾选", "chatWindow.optionUnchecked": "未勾选", + "chatWindow.other": "其他", + "chatWindow.otherPlaceholder": "请输入你的答案", + "chatWindow.formOtherUnlisted": "请输入上述选项之外的答案。", "chatWindow.thinking": "思考中...", "chatWindow.subagentCount.one": "{count} 个子代理", "chatWindow.subagentCount.other": "{count} 个子代理", diff --git a/lib/i18n/locales/zh-TW.json b/lib/i18n/locales/zh-TW.json index 23ad66f5d..d5e3729d3 100644 --- a/lib/i18n/locales/zh-TW.json +++ b/lib/i18n/locales/zh-TW.json @@ -603,6 +603,9 @@ "chatWindow.askOther": "其他:自行輸入答案", "chatWindow.optionChecked": "已勾選", "chatWindow.optionUnchecked": "未勾選", + "chatWindow.other": "其他", + "chatWindow.otherPlaceholder": "請輸入你的答案", + "chatWindow.formOtherUnlisted": "請輸入上述選項以外的答案。", "chatWindow.thinking": "思考中...", "chatWindow.subagentCount.one": "{count} 個子代理", "chatWindow.subagentCount.other": "{count} 個子代理", diff --git a/lib/notification-events.ts b/lib/notification-events.ts index 3939f96f4..69f6f5911 100644 --- a/lib/notification-events.ts +++ b/lib/notification-events.ts @@ -119,7 +119,7 @@ export function renderNotification(event: NotificationEvent, t: Translate): Rend const MODEL_SWITCH_NOTICE_SOURCES = new Set(["prewalk", "plan-yolo"]); /** Extension UI methods that block the agent until the user answers (rpc-manager replays these too). */ -export const BLOCKING_UI_METHODS = new Set(["select", "confirm", "input", "editor", "ask", "open_url"]); +export const BLOCKING_UI_METHODS = new Set(["select", "confirm", "input", "editor", "ask", "form", "open_url"]); const NOTIFICATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; diff --git a/lib/pi-types.ts b/lib/pi-types.ts index 5a82509ec..31e220b2e 100644 --- a/lib/pi-types.ts +++ b/lib/pi-types.ts @@ -251,6 +251,7 @@ export type OmpExtensionUiRequest = | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number; expiresAt?: number } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string; promptStyle?: boolean; timeout?: number; expiresAt?: number } | { type: "extension_ui_request"; id: string; method: "ask"; questions: RpcAskDialogQuestion[]; timeout?: number; expiresAt?: number } + | { type: "extension_ui_request"; id: string; method: "form"; html: string; timeout?: number; expiresAt?: number } | { type: "extension_ui_request"; id: string; method: "cancel"; targetId: string } | { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } | { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index c85777264..e5b1b2397 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -1304,6 +1304,69 @@ test("startup opts into the ask dialog and still starts when omp rejects the com assert.equal(wrapper.sessionId, "ask-session"); }); +test("an omp without set_form_dialog still becomes ready and accepts a legacy select answer", async (t) => { + let receive; + let formRejected = false; + const sentFrames = []; + const wrapper = new SnapshotWrapper({ + isAlive: true, + waitReady: async () => ({ type: "ready" }), + negotiateProtocol: async () => {}, + onFrame(listener) { receive = listener; return () => {}; }, + async sendCommand(command) { + if (command.type === "set_form_dialog") { + formRejected = true; + throw new Error("Unknown command: set_form_dialog"); + } + return command.type === "get_state" + ? { sessionId: "legacy-select", isStreaming: false, isCompacting: false } + : {}; + }, + sendFrame(frame) { sentFrames.push(frame); }, + dispose: async () => {}, + }, process.cwd(), null, false, "", undefined, localHost); + wrapper.start(); + t.after(() => wrapper.destroyAndWait()); + const events = []; + wrapper.onEvent(event => events.push(event)); + await wrapper.waitUntilReady(); + assert.equal(formRejected, true); + assert.equal(wrapper.sessionId, "legacy-select"); + assert.equal(events.some(event => event.type === "notice"), false); + + receive({ type: "extension_ui_request", id: "legacy", method: "select", title: "Pick", options: ["A", "B"] }); + assert.equal(events.at(-1).method, "select"); + await wrapper.send({ type: "extension_ui_response", id: "legacy", value: "B" }); + assert.deepEqual(sentFrames, [{ type: "extension_ui_response", id: "legacy", value: "B" }]); + const replay = []; + wrapper.onEvent(event => replay.push(event)); + assert.deepEqual(replay, [], "the accepted legacy answer clears the pending dialog"); +}); + +test("pending forms replay until submitted or cancelled and close in every tab", async (t) => { + const { wrapper, emit, sentFrames } = snapshotSession(t); + for (const response of [{ data: { count: 2, choice: ["A", "B"] } }, { cancelled: true }, { cancelled: true, timedOut: true }]) { + const request = { type: "extension_ui_request", id: "form", method: "form", html: '
' }; + emit(request); + assert.equal(wrapper.isRunning(), true); + const events = []; + const detach = wrapper.onEvent(event => events.push(event)); + assert.equal(events[0]?.html, request.html, "a new tab receives the full pending form"); + await wrapper.send({ type: "extension_ui_response", id: "form", ...response }); + assert.deepEqual(sentFrames.at(-1), { type: "extension_ui_response", id: "form", ...response }); + assert.equal(events.at(-1).targetId, "form", "answering dismisses the other tab's form"); + detach(); + const replay = []; + wrapper.onEvent(event => replay.push(event))(); + assert.deepEqual(replay, []); + } + emit({ type: "extension_ui_request", id: "native-cancel", method: "form", html: "
" }); + emit({ type: "extension_ui_request", id: "cancel", method: "cancel", targetId: "native-cancel" }); + const replay = []; + wrapper.onEvent(event => replay.push(event)); + assert.deepEqual(replay, [], "omp's cancellation also removes the pending form"); +}); + test("ask dialogs stay pending until a well-formed answers payload is forwarded", async (t) => { const { wrapper, emit, sentFrames } = snapshotSession(t); emit({ @@ -3686,7 +3749,7 @@ test("adoptRpcSession re-syncs a live child with no spawn and no ready wait", as const session = await adoptRpcSession({ host: adoptionHost("adopt-box", transport), transport, sessionId: id, cwd: "/w", replay: [] }); t.after(() => session.destroyAndWait()); - assert.deepEqual(transport.sent, ["negotiate_protocol", "set_subagent_subscription", "set_ask_dialog", "set_event_filter", "get_state", "get_subagents", "get_btw_history"]); + assert.deepEqual(transport.sent, ["negotiate_protocol", "set_subagent_subscription", "set_ask_dialog", "set_form_dialog", "set_event_filter", "get_state", "get_subagents", "get_btw_history"]); assert.equal(session.isRunning(), true, "omp reported a run in progress"); assert.equal(session.sessionFile, `/nonexistent/${id}.jsonl`); assert.equal(getRpcSession(id), session); @@ -3694,7 +3757,7 @@ test("adoptRpcSession re-syncs a live child with no spawn and no ready wait", as assert.equal(transport.kills, 0); }); -test("an adopted child's open requests come back: an ask for the next tab, a host tool call for an open tab", async (t) => { +test("an adopted child's open requests come back: ask and form for the next tab, a host tool call for an open tab", async (t) => { const { adoptRpcSession } = await import("./rpc-manager.ts"); const id = "11111111-2222-4333-8444-adadadadad02"; const transport = adoptedTransport(id); @@ -3702,13 +3765,14 @@ test("an adopted child's open requests come back: an ask for the next tab, a hos const leave = subscribeHostToolCalls((call) => calls.push(call)); t.after(leave); const ask = { type: "extension_ui_request", id: "q1", method: "ask", questions: [{ id: "q", question: "Pick", options: [{ label: "A" }] }] }; + const form = { type: "extension_ui_request", id: "f1", method: "form", html: '
' }; const call = { type: "host_tool_call", id: "h1", toolName: "open_url", arguments: { url: "https://a.test" } }; const session = await adoptRpcSession({ host: adoptionHost("replay-box", transport), transport, sessionId: id, cwd: "/w", - replay: [JSON.stringify(ask), JSON.stringify(call)], + replay: [JSON.stringify(ask), JSON.stringify(form), JSON.stringify(call)], }); t.after(async () => { await session.destroyAndWait(); @@ -3720,7 +3784,7 @@ test("an adopted child's open requests come back: an ask for the next tab, a hos assert.deepEqual(hostToolResults(transport), []); const events = []; session.onEvent((event) => events.push(event)); - assert.deepEqual(events.map((event) => [event.type, event.id]), [["extension_ui_request", "q1"]]); + assert.deepEqual(events.map((event) => [event.type, event.id]), [["extension_ui_request", "q1"], ["extension_ui_request", "f1"]]); assert.equal(session.isRunning(), true, "the run waits on its user"); }); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 3f1f0244f..ac5161da7 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -844,6 +844,7 @@ export class AgentSessionWrapper { await this.proc.negotiateProtocol(ready); await this.proc.sendCommand({ type: "set_subagent_subscription", level: "events" }).catch(() => {}); await this.proc.sendCommand({ type: "set_ask_dialog", enabled: true }, GET_STATE_TIMEOUT_MS).catch(() => {}); + await this.proc.sendCommand({ type: "set_form_dialog", enabled: true }, GET_STATE_TIMEOUT_MS).catch(() => {}); await this.enableDeltaUpdates(this.proc); } const state = await this.getStateWithTimeout(); @@ -938,6 +939,7 @@ export class AgentSessionWrapper { await this.proc.negotiateProtocol(await this.proc.waitReady(), GET_STATE_TIMEOUT_MS); await optional({ type: "set_subagent_subscription", level: "events" }); await optional({ type: "set_ask_dialog", enabled: true }); + await optional({ type: "set_form_dialog", enabled: true }); await this.enableDeltaUpdates(this.proc); } // omp's state has no shell-command flag: a `!!` command the lost server started @@ -2392,6 +2394,7 @@ export class AgentSessionWrapper { await proc.negotiateProtocol(ready); await proc.sendCommand({ type: "set_subagent_subscription", level: "events" }).catch(() => {}); await proc.sendCommand({ type: "set_ask_dialog", enabled: true }, GET_STATE_TIMEOUT_MS).catch(() => {}); + await proc.sendCommand({ type: "set_form_dialog", enabled: true }, GET_STATE_TIMEOUT_MS).catch(() => {}); await this.enableDeltaUpdates(proc); } const state = await proc.sendCommand({ type: "get_state" }, GET_STATE_TIMEOUT_MS); diff --git a/lib/types.ts b/lib/types.ts index 9e8dbf028..355c97ba3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -240,6 +240,14 @@ export type ExtensionUiRequest = timeout?: number; expiresAt?: number; } + | { + type: "extension_ui_request"; + id: string; + method: "form"; + html: string; + timeout?: number; + expiresAt?: number; + } | { type: "extension_ui_request"; id: string;