Files
openclaw/src/wizard/session.ts
Peter Steinberger e3c5821d98 feat(ui): redesign Channels page with guided channel setup wizard (#106469)
* feat(ui): redesign Channels page with guided channel setup wizard

Adds wizard.start flow=channels gateway RPC (additive protocol change) that
drives the shared channel-setup wizard (openclaw channels add) over the
session step protocol. Control UI Channels page becomes a card hub with
plugin-art covers, guided per-channel setup modal (WhatsApp QR pairing via
web.login.*, BotFather/Slack/Discord helper links), and a detail overlay
hosting the full schema config form.

* test(ui): cover channel wizard controller; refresh channels view test props

* chore(mock): use non-token-shaped wizard placeholder

* test(gateway): scanner-safe auth local in channels wizard test

* fix(ui): cancel gateway wizard session on channels page disconnect

* fix(ui): adopt browse-all channel pick and guard dirty config before setup

* fix(ui): cancel gateway session created by a stale wizard.start

* fix(ui): adopt multiselect channel picks; drop redundant String()

* fix(ui): track all wizard-adopted channels so WhatsApp QR runs regardless of pick order

* feat(gateway): report configured channels on terminal channel-wizard result

Replaces the Control UI's channel-adoption heuristic with the authoritative
outcome: the channels flow reports its actual selection after config commit,
the wizard session surfaces it as an additive channels field on the terminal
wizard.next result, and the UI keys WhatsApp QR linking off that.

* refactor(ui): align channels hub with the unified settings design language

Hub becomes settings-language rows (44px art tiles, status dots, uppercase
section headings, hairline groups) instead of a card gallery; the detail
overlay hosts the migrated per-channel settings sections with Run setup in
the header. Wizard dialog and tile/cover styles move to spacing tokens.

* chore(i18n): translate channels hub/setup strings; refresh raw-copy baseline

* refactor: satisfy LOC ratchet and dead-export gates

Split mock-dev channel/plugin fixtures into their own modules, move wizard
runner types/defaults to server-methods/wizard.ts, extract the channels page
wizard host and nostr profile HTTP ops, and unexport internal-only types
(incl. the unused config-form SECTION_META barrel re-export).

* chore(i18n): retranslate channels strings on rebased locale bundles

* fix(test): drop redundant String() in channels wizard e2e

* fix: address channel-wizard review findings

- lock wizard cancellation before durable installs/config writes
- report configured channel accounts on the terminal wizard result and
  start WhatsApp QR pairing for that account (web.login accountId)
- forward request options through the browser gateway client so wizard
  RPC timeouts apply
- render skipped setup as a no-change completion
- keep detail/advanced config reachable for unconfigured channels
- surface the dirty-config warning inside the detail overlay
- adopt the picked channel for wizard title/links in browse-all flows

* fix(ui): local wizard RPC timeout; translate no-change completion strings
2026-07-13 11:49:42 -07:00

413 lines
12 KiB
TypeScript

// Wizard session helpers track onboarding session ids and state.
import { randomUUID } from "node:crypto";
import { createDeferred, type Deferred } from "../shared/deferred.js";
import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js";
// WizardSession exposes interactive setup as a step/answer protocol for remote
// clients while reusing the same WizardPrompter contract as the local CLI.
type WizardStepOption = {
value: unknown;
label: string;
hint?: string;
};
export type WizardStep = {
id: string;
type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action";
title?: string;
message?: string;
format?: "plain";
options?: WizardStepOption[];
initialValue?: unknown;
placeholder?: string;
sensitive?: boolean;
executor?: "gateway" | "client";
externalUrl?: string;
deviceCode?: {
code: string;
expiresInMinutes?: number;
message?: string;
};
};
type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
type WizardNextResult = {
done: boolean;
step?: WizardStep;
status: WizardSessionStatus;
error?: string;
channels?: string[];
accounts?: Array<{ channel: string; accountId: string }>;
};
function normalizeTextAnswer(value: unknown): string | undefined {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
return undefined;
}
class WizardSessionPrompter implements WizardPrompter {
constructor(private session: WizardSession) {}
async intro(title: string): Promise<void> {
await this.prompt({
type: "note",
title,
message: "",
executor: "client",
});
}
async outro(message: string): Promise<void> {
await this.prompt({
type: "note",
title: "Done",
message,
executor: "client",
});
}
async note(message: string, title?: string): Promise<void> {
await this.prompt({ type: "note", title, message, executor: "client" });
}
async deviceCode(params: {
title: string;
code: string;
expiresInMinutes?: number;
message?: string;
}): Promise<void> {
const fallbackMessage = [
params.message ?? "Enter this one-time code on the provider's sign-in page.",
`Code: ${params.code}`,
...(params.expiresInMinutes
? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`]
: []),
].join("\n");
await this.prompt({
type: "note",
title: params.title,
message: fallbackMessage,
deviceCode: {
code: params.code,
...(params.expiresInMinutes ? { expiresInMinutes: params.expiresInMinutes } : {}),
...(params.message ? { message: params.message } : {}),
},
executor: "client",
});
}
async plain(message: string): Promise<void> {
await this.prompt({ type: "note", message, format: "plain", executor: "client" });
}
async select<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValue?: T;
}): Promise<T> {
const res = await this.prompt({
type: "select",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValue,
executor: "client",
});
return res as T;
}
async multiselect<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValues?: T[];
}): Promise<T[]> {
const res = await this.prompt({
type: "multiselect",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValues,
executor: "client",
});
return (Array.isArray(res) ? res : []) as T[];
}
async text(params: {
message: string;
initialValue?: string;
placeholder?: string;
validate?: (value: string) => string | undefined;
sensitive?: boolean;
}): Promise<string> {
const res = await this.session.awaitAnswer(
this.createStep({
type: "text",
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
sensitive: params.sensitive,
executor: "client",
}),
params.validate,
);
const value =
res === null || res === undefined
? ""
: typeof res === "string"
? res
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
? String(res)
: "";
return value;
}
async confirm(params: Parameters<WizardPrompter["confirm"]>[0]): Promise<boolean> {
const res = await this.prompt({
type: "confirm",
message: params.message,
initialValue: params.initialValue,
executor: "client",
});
return Boolean(res);
}
progress(_label: string): WizardProgress {
return {
update: (_message) => {},
stop: (_message) => {},
};
}
async openUrl(url: string): Promise<void> {
this.session.queueExternalUrl(url);
}
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
return await this.session.awaitAnswer(this.createStep(step));
}
private createStep(step: Omit<WizardStep, "id">): WizardStep {
// Each emitted step receives an id so remote clients can answer the exact
// pending prompt and stale answers can be rejected. Explicit browser
// destinations bind to the very next step regardless of its input type.
const externalUrl = this.session.consumeExternalUrl();
return {
...step,
...(externalUrl ? { externalUrl } : {}),
id: randomUUID(),
};
}
}
export class WizardSession {
private readonly abortController = new AbortController();
private readonly expiryTimer: ReturnType<typeof setTimeout> | undefined;
private currentStep: WizardStep | null = null;
private stepDeferred: Deferred<WizardStep | null> | null = null;
private pendingTerminalResolution = false;
private cancellationLocked = false;
private pendingExternalUrl: string | undefined;
private answerDeferred = new Map<
string,
{
deferred: Deferred<unknown>;
text: boolean;
validate?: (value: string) => string | undefined;
}
>();
private status: WizardSessionStatus = "running";
private error: string | undefined;
private configuredAccounts: Array<{ channel: string; accountId: string }> | undefined;
constructor(
private runner: (
prompter: WizardPrompter,
signal: AbortSignal,
session: WizardSession,
) => Promise<void>,
options?: { timeoutMs?: number },
) {
const prompter = new WizardSessionPrompter(this);
if (options?.timeoutMs !== undefined) {
this.expiryTimer = setTimeout(() => this.cancel(), options.timeoutMs);
this.expiryTimer.unref?.();
}
void this.run(prompter);
}
async next(): Promise<WizardNextResult> {
if (this.currentStep) {
return { done: false, step: this.currentStep, status: this.status };
}
if (this.pendingTerminalResolution) {
this.pendingTerminalResolution = false;
return this.terminalResult();
}
if (this.status !== "running") {
return this.terminalResult();
}
if (!this.stepDeferred) {
this.stepDeferred = createDeferred();
}
const step = await this.stepDeferred.promise;
if (step) {
return { done: false, step, status: this.status };
}
return this.terminalResult();
}
private terminalResult(): WizardNextResult {
if (!this.configuredAccounts) {
return { done: true, status: this.status, error: this.error };
}
return {
done: true,
status: this.status,
error: this.error,
channels: [...new Set(this.configuredAccounts.map((entry) => entry.channel))],
accounts: this.configuredAccounts.map((entry) => ({ ...entry })),
};
}
/** Record what the channels flow actually configured (channels flow only). */
setConfiguredAccounts(accounts: ReadonlyArray<{ channel: string; accountId: string }>) {
this.configuredAccounts = accounts.map((entry) => ({ ...entry }));
}
async answer(stepId: string, value: unknown): Promise<string | undefined> {
const pending = this.answerDeferred.get(stepId);
if (!pending) {
throw new Error("wizard: no pending step");
}
const normalizedValue = pending.text ? normalizeTextAnswer(value) : value;
if (pending.text && normalizedValue === undefined) {
return "wizard: text answer must be a scalar value";
}
const validationError = pending.validate?.(normalizedValue as string) ?? undefined;
if (validationError) {
return validationError;
}
this.answerDeferred.delete(stepId);
this.currentStep = null;
pending.deferred.resolve(normalizedValue);
return undefined;
}
cancel(): boolean {
if (this.status !== "running" || this.cancellationLocked) {
return false;
}
this.status = "cancelled";
this.error = "cancelled";
this.abortController.abort(new WizardCancelledError());
this.currentStep = null;
for (const [, pending] of this.answerDeferred) {
// Reject all pending prompt promises so the runner can unwind through its
// normal cancellation path.
pending.deferred.reject(new WizardCancelledError());
}
this.answerDeferred.clear();
this.resolveStep(null);
return true;
}
/** The underlying mutation crossed its durable commit point and must finish. */
lockCancellation() {
this.cancellationLocked = true;
}
get signal(): AbortSignal {
return this.abortController.signal;
}
pushStep(step: WizardStep) {
this.currentStep = step;
this.resolveStep(step);
}
queueExternalUrl(url: string) {
this.pendingExternalUrl = url;
}
consumeExternalUrl(): string | undefined {
const url = this.pendingExternalUrl;
this.pendingExternalUrl = undefined;
return url;
}
private async run(prompter: WizardPrompter) {
try {
await this.runner(prompter, this.signal, this);
if (this.status === "running") {
this.status = "done";
}
} catch (err) {
if (this.status !== "running") {
return;
}
if (err instanceof WizardCancelledError) {
this.status = "cancelled";
this.error = err.message;
} else {
this.status = "error";
this.error = String(err);
}
} finally {
if (this.expiryTimer) {
clearTimeout(this.expiryTimer);
}
this.resolveStep(null);
}
}
async awaitAnswer(
step: WizardStep,
validate?: (value: string) => string | undefined,
): Promise<unknown> {
if (this.status !== "running") {
throw new Error("wizard: session not running");
}
this.pushStep(step);
const deferred = createDeferred<unknown>();
this.answerDeferred.set(step.id, { deferred, text: step.type === "text", validate });
return await deferred.promise;
}
private resolveStep(step: WizardStep | null) {
if (!this.stepDeferred) {
if (step === null) {
// The runner can finish immediately after an answer before next() has
// installed a waiter; remember that terminal state for the next poll.
this.pendingTerminalResolution = true;
}
return;
}
const deferred = this.stepDeferred;
this.stepDeferred = null;
deferred.resolve(step);
}
getStatus(): WizardSessionStatus {
return this.status;
}
getError(): string | undefined {
return this.error;
}
}