Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<agent dir>/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.
Expand Down
2 changes: 1 addition & 1 deletion components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
useEffect(() => {
// A hidden composer that keeps focus keeps the phone keyboard up: the
Expand Down
55 changes: 55 additions & 0 deletions components/ExtensionDialog.form.test.mjs
Original file line number Diff line number Diff line change
@@ -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: '<form><label>Name<input name="name" required value="Ada"></label></form>', ...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: '<input name="text" value="new">' }, 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: '<input name="text" value="keep">' };
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");
});
51 changes: 44 additions & 7 deletions components/ExtensionDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
"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";
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<string, string | number | string[]> };

// 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.
Expand Down Expand Up @@ -241,6 +243,9 @@ export function ExtensionDialog({
attached?: boolean;
}) {
const { t } = useI18n();
const formId = useId();
const answeredFormRef = useRef<string | null>(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<string | null>(null);
Expand Down Expand Up @@ -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
Expand All @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -422,6 +454,9 @@ export function ExtensionDialog({
</div>

<div style={{ padding: 14 }}>
{request.method === "form" && (
<FormDialog key={request.id} html={request.html} formId={formId} onSubmit={(data) => respond({ data })} />
)}
{request.method === "confirm" && (
<div style={{ color: "var(--text-muted)", fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{request.message}</div>
)}
Expand Down Expand Up @@ -748,7 +783,9 @@ export function ExtensionDialog({
</button>
) : request.method !== "select" ? (
<button
onClick={submitValue}
type={request.method === "form" ? "submit" : "button"}
form={request.method === "form" ? formId : undefined}
onClick={request.method === "form" ? undefined : submitValue}
style={{
padding: "6px 10px",
minHeight: touchTargets === "accessible" ? 44 : undefined,
Expand Down
Loading
Loading