mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
cef071582e
* feat(gateway): add live device scope upgrades * feat(ui): add limited-access upgrade flow * fix(protocol): refresh Swift scope upgrade models * perf(ui): lazy-load device scope upgrades * fix(ci): complete scope upgrade generated surfaces * perf(ui): lazy-load GitHub link hovercards * fix(ui): keep admin repair guidance focusable * fix(ui): gate and refresh scope upgrade banner * refactor(ui): keep gateway client within line budget * fix(ci): align rebased scope upgrade checks * fix(ui): resolve scope upgrade in browser tests * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): honor refreshed scope upgrade deadline * fix(gateway): coalesce scope upgrade waiters * fix(ui): gate scope upgrade actions * chore(plugin-sdk): refresh rebased API baseline * fix(scope-upgrade): return canonical request ids * fix(ui): preserve gateway event type binding * fix(protocol): generate scope upgrade result models * fix(ui): preserve scope upgrade recovery guidance * chore(plugin-sdk): refresh rebased API baseline * test(ui): avoid scope upgrade navigation race * docs(control-ui): clarify scope upgrade approver * test(gateway): align appended method counts * chore(plugin-sdk): refresh rebased API baseline * refactor(ui): keep place picker within line budget * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * chore(plugin-sdk): refresh rebased API baseline * fix(gateway): preserve scope-upgrade browser origin
218 lines
6.7 KiB
TypeScript
218 lines
6.7 KiB
TypeScript
import { html, nothing } from "lit";
|
|
import { property } from "lit/decorators.js";
|
|
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
|
import { t } from "../i18n/index.ts";
|
|
import { formatUiError } from "../lib/format-error.ts";
|
|
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
|
import { readScopeUpgradeAvailability, type ScopeUpgradeState } from "./device-scope-upgrade.ts";
|
|
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
|
|
|
|
type UpgradeOperation = {
|
|
client: GatewayBrowserClient;
|
|
};
|
|
|
|
/** Owns the explicit live scope-upgrade action and its cross-route banner state. */
|
|
export class ScopeUpgradeController {
|
|
private current: ApplicationGatewaySnapshot;
|
|
private operation: UpgradeOperation | null = null;
|
|
private value: ScopeUpgradeState = { phase: "hidden" };
|
|
|
|
constructor(
|
|
initial: ApplicationGatewaySnapshot,
|
|
private readonly onChange: () => void,
|
|
) {
|
|
this.current = initial;
|
|
this.sync(initial);
|
|
}
|
|
|
|
get state(): ScopeUpgradeState {
|
|
return this.value;
|
|
}
|
|
|
|
sync(snapshot: ApplicationGatewaySnapshot): void {
|
|
this.current = snapshot;
|
|
const client = snapshot.client;
|
|
const availability = readScopeUpgradeAvailability(snapshot);
|
|
if (!client || availability.phase !== "available") {
|
|
this.retireOperation();
|
|
this.setState(availability);
|
|
return;
|
|
}
|
|
if (this.operation && this.operation.client !== client) {
|
|
this.retireOperation();
|
|
this.setState({ phase: "available" });
|
|
}
|
|
if (this.value.phase === "hidden" || this.value.phase === "guidance") {
|
|
this.setState({ phase: "available" });
|
|
}
|
|
}
|
|
|
|
request(): void {
|
|
this.start(false);
|
|
}
|
|
|
|
retry(): void {
|
|
this.start(true);
|
|
}
|
|
|
|
cancel(): void {
|
|
this.retireOperation();
|
|
this.setState(readScopeUpgradeAvailability(this.current));
|
|
}
|
|
|
|
dispose(): void {
|
|
this.retireOperation();
|
|
}
|
|
|
|
private start(retry: boolean): void {
|
|
const client = this.current.client;
|
|
if (!client || readScopeUpgradeAvailability(this.current).phase !== "available") {
|
|
return;
|
|
}
|
|
if (this.operation) {
|
|
if (!retry) {
|
|
return;
|
|
}
|
|
this.retireOperation();
|
|
}
|
|
const operation = { client };
|
|
this.operation = operation;
|
|
this.setState({ phase: "requesting" });
|
|
void client
|
|
.requestScopeUpgrade({
|
|
onPending: (requestId) => {
|
|
if (this.isCurrent(operation)) {
|
|
this.setState({ phase: "pending", requestId });
|
|
}
|
|
},
|
|
})
|
|
.then((result) => {
|
|
if (!this.isCurrent(operation) || result.status === "approved") {
|
|
return;
|
|
}
|
|
this.setState({
|
|
phase: "rejected",
|
|
requestId: result.requestId,
|
|
expired: result.status === "expired",
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!this.isCurrent(operation) || (error instanceof Error && error.name === "AbortError")) {
|
|
return;
|
|
}
|
|
this.setState({ phase: "error", message: formatUiError(error) });
|
|
})
|
|
.finally(() => {
|
|
if (this.isCurrent(operation)) {
|
|
this.operation = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
private isCurrent(operation: UpgradeOperation): boolean {
|
|
return this.operation === operation && this.current.client === operation.client;
|
|
}
|
|
|
|
private retireOperation(): void {
|
|
const operation = this.operation;
|
|
this.operation = null;
|
|
operation?.client.cancelScopeUpgrade();
|
|
}
|
|
|
|
private setState(next: ScopeUpgradeState): void {
|
|
if (JSON.stringify(this.value) === JSON.stringify(next)) {
|
|
return;
|
|
}
|
|
this.value = next;
|
|
this.onChange();
|
|
}
|
|
}
|
|
|
|
type ScopeUpgradeBannerProps = {
|
|
snapshot: ApplicationGatewaySnapshot;
|
|
chromeOffset: boolean;
|
|
};
|
|
|
|
class ScopeUpgradeBanner extends OpenClawLightDomContentsElement {
|
|
@property({ attribute: false }) props?: ScopeUpgradeBannerProps;
|
|
private controller?: ScopeUpgradeController;
|
|
|
|
protected override updated(): void {
|
|
const snapshot = this.props?.snapshot;
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
if (this.controller) {
|
|
this.controller.sync(snapshot);
|
|
} else {
|
|
this.controller = new ScopeUpgradeController(snapshot, () => this.requestUpdate());
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
this.controller?.dispose();
|
|
this.controller = undefined;
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
override render() {
|
|
const props = this.props;
|
|
const state =
|
|
this.controller?.state ??
|
|
(props ? readScopeUpgradeAvailability(props.snapshot) : { phase: "hidden" as const });
|
|
if (!props || state.phase === "hidden") {
|
|
return nothing;
|
|
}
|
|
const retryable =
|
|
state.phase === "pending" || state.phase === "rejected" || state.phase === "error";
|
|
const text =
|
|
state.phase === "guidance"
|
|
? t("connection.scopeUpgrade.guidance")
|
|
: state.phase === "available"
|
|
? t("connection.scopeUpgrade.limited")
|
|
: state.phase === "requesting"
|
|
? t("connection.scopeUpgrade.requesting")
|
|
: state.phase === "pending"
|
|
? t("connection.scopeUpgrade.pending")
|
|
: state.phase === "rejected"
|
|
? t(
|
|
state.expired
|
|
? "connection.scopeUpgrade.expired"
|
|
: "connection.scopeUpgrade.rejected",
|
|
)
|
|
: t("connection.scopeUpgrade.error", { error: state.message });
|
|
return html`<div
|
|
class="callout ${state.phase === "error" || state.phase === "rejected"
|
|
? "danger"
|
|
: "warn"} callout--action"
|
|
style=${props.chromeOffset ? "margin-left: 72px" : nothing}
|
|
role="status"
|
|
>
|
|
<span class="callout__content">${text}</span>
|
|
${state.phase === "available"
|
|
? html`<button class="btn btn--sm" type="button" @click=${() => this.controller?.request()}>
|
|
${t("connection.scopeUpgrade.request")}
|
|
</button>`
|
|
: state.phase === "requesting"
|
|
? html`<button class="btn btn--sm" type="button" disabled>
|
|
${t("connection.scopeUpgrade.requestingAction")}
|
|
</button>`
|
|
: retryable
|
|
? html`
|
|
<button class="btn btn--sm" type="button" @click=${() => this.controller?.retry()}>
|
|
${t("connection.scopeUpgrade.retry")}
|
|
</button>
|
|
<button class="btn btn--sm" type="button" @click=${() => this.controller?.cancel()}>
|
|
${t("connection.scopeUpgrade.cancel")}
|
|
</button>
|
|
`
|
|
: nothing}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
if (!customElements.get("openclaw-device-scope-upgrade-banner")) {
|
|
customElements.define("openclaw-device-scope-upgrade-banner", ScopeUpgradeBanner);
|
|
}
|