fix(ui): resume session starts after Gateway reconnects (#128661)

* fix(ui): resume session creation after reconnects

* improve(ui): remove redundant appearance reset buttons

* fix(ui): align session protocol and appearance validation

* fix(ui): retain promoted placement session ownership

* fix(gateway): isolate session creation capacity by owner

* test(ui): stabilize hovercard bridge pointer movement
This commit is contained in:
Peter Steinberger
2026-08-24 06:36:51 -07:00
committed by GitHub
parent fb576e3518
commit b145e25fea
26 changed files with 1183 additions and 289 deletions
@@ -8971,6 +8971,7 @@ public struct SessionWorktreeInfo: Codable, Sendable {
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let idempotencykey: String?
public let agentid: String?
public let label: String?
public let category: String?
@@ -8999,6 +9000,7 @@ public struct SessionsCreateParams: Codable, Sendable {
public init(
key: String? = nil,
idempotencykey: String? = nil,
agentid: String? = nil,
label: String? = nil,
category: String? = nil,
@@ -9026,6 +9028,7 @@ public struct SessionsCreateParams: Codable, Sendable {
cwd: String? = nil)
{
self.key = key
self.idempotencykey = idempotencykey
self.agentid = agentid
self.label = label
self.category = category
@@ -9055,6 +9058,7 @@ public struct SessionsCreateParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case key
case idempotencykey = "idempotencyKey"
case agentid = "agentId"
case label
case category
+4
View File
@@ -29,6 +29,10 @@ export {
export * from "./schema/session-classification.js";
export * from "./schema/sessions-suggestions.js";
export * from "./schema/sessions-delete.js";
export {
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
SESSION_CREATE_RETRY_WINDOW_MS,
} from "./schema/sessions-create.js";
export * from "./schema/projects.js";
export * from "./migration-api.js";
export type * from "./public-session-catalog.js";
@@ -1,7 +1,16 @@
import { describe, expect, it } from "vitest";
import { validateSessionsCreateParams } from "../index.js";
import {
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
SESSION_CREATE_RETRY_WINDOW_MS,
validateSessionsCreateParams,
} from "../index.js";
describe("sessions.create schema", () => {
it("retains successful creates beyond the client's bounded retry window", () => {
expect(SESSION_CREATE_RETRY_WINDOW_MS).toBe(4 * 60_000);
expect(SESSION_CREATE_IDEMPOTENCY_RETENTION_MS).toBeGreaterThan(SESSION_CREATE_RETRY_WINDOW_MS);
});
it.each(["read-only", "guarded", "workspace", "full"])(
"accepts the closed permission mode %s",
(permissionMode) => {
@@ -24,4 +33,11 @@ describe("sessions.create schema", () => {
it("rejects unknown visibility values", () => {
expect(validateSessionsCreateParams({ agentId: "main", visibility: "private" })).toBe(false);
});
it("accepts a nonempty creation idempotency key", () => {
expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "start-once" })).toBe(
true,
);
expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "" })).toBe(false);
});
});
@@ -5,9 +5,13 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js";
import { SessionPermissionModeSchema } from "./sessions-row.js";
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
export const SESSION_CREATE_RETRY_WINDOW_MS = 4 * 60_000;
export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000;
/** Creates or adopts a session with optional model, thinking, label, and parent linkage. */
export const SessionsCreateParamsSchema = closedObject({
key: Type.Optional(NonEmptyString),
idempotencyKey: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
category: Type.Optional(SessionLabelString),
@@ -0,0 +1,271 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_CREATE_IDEMPOTENCY_RETENTION_MS } from "../../../packages/gateway-protocol/src/index.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { DEDUPE_MAX } from "../server-constants.js";
import { idempotentSessionCreate } from "./session-create-idempotency.js";
import type {
GatewayRequestContext,
GatewayRequestHandler,
GatewayRequestHandlerOptions,
RespondFn,
} from "./types.js";
afterEach(() => {
vi.restoreAllMocks();
});
function createFixture(handler?: GatewayRequestHandler) {
const context = { dedupe: new Map() } as GatewayRequestContext;
const execute = vi.fn<GatewayRequestHandler>(
handler ??
((request) => {
request.respond(true, { key: `agent:main:${String(request.params.idempotencyKey)}` });
}),
);
const wrapped = idempotentSessionCreate(execute);
const client = {
authenticatedUserId: "owner",
connect: {
minProtocol: 1,
maxProtocol: 1,
client: { id: "test", version: "1", platform: "test", mode: "test" },
device: {
id: "device",
publicKey: "key",
signature: "signature",
signedAt: 1,
nonce: "nonce",
},
role: "operator",
scopes: ["operator.admin", "operator.write"],
},
} satisfies NonNullable<GatewayRequestHandlerOptions["client"]>;
function invoke(
params: Record<string, unknown> = { agentId: "main", idempotencyKey: "create-once" },
connection: typeof client = client,
) {
const respond = vi.fn<RespondFn>();
const request: GatewayRequestHandlerOptions = {
req: { type: "req", id: "1", method: "sessions.create" },
params,
client: connection,
context,
respond,
isWebchatConnect: () => false,
};
return { done: Promise.resolve(wrapped(request)), respond };
}
return { client, context, execute, invoke };
}
describe("sessions.create process-lifetime idempotency", () => {
it("replays reordered requests and added authorization without accepting downgraded scopes", async () => {
const { client, context, execute, invoke } = createFixture();
const first = invoke({ agentId: "main", idempotencyKey: "create-once", message: "hello" });
await first.done;
context.dedupe.clear();
const added = invoke(
{ message: "hello", idempotencyKey: "create-once", agentId: "main" },
{
...client,
connect: {
...client.connect,
scopes: ["operator.write", "operator.read", "operator.admin"],
},
},
);
await added.done;
expect(execute).toHaveBeenCalledOnce();
expect(added.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-once" }, undefined, {
cached: true,
});
const downgraded = invoke(
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
{ ...client, connect: { ...client.connect, scopes: ["operator.write"] } },
);
await downgraded.done;
expect(downgraded.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "FORBIDDEN", message: "missing scope: operator.admin" }),
);
expect(execute).toHaveBeenCalledOnce();
const changedRole = invoke(
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
{ ...client, connect: { ...client.connect, role: "node" } },
);
await changedRole.done;
expect(changedRole.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "FORBIDDEN" }),
);
expect(execute).toHaveBeenCalledOnce();
const conflict = invoke({
agentId: "main",
idempotencyKey: "create-once",
message: "different",
});
await conflict.done;
expect(conflict.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
expect(execute).toHaveBeenCalledOnce();
const differentDevice = invoke(
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
{
...client,
connect: {
...client.connect,
device: { ...client.connect.device, id: "other-device" },
},
},
);
await differentDevice.done;
expect(differentDevice.respond).toHaveBeenCalledWith(
true,
{ key: "agent:main:create-once" },
undefined,
{ cached: true },
);
expect(execute).toHaveBeenCalledOnce();
});
it("rejects idempotent creation without a principal or device namespace", async () => {
const { execute, invoke } = createFixture();
const anonymous = invoke({ agentId: "main", idempotencyKey: "create-once" }, null as never);
await anonymous.done;
expect(anonymous.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: expect.stringContaining("identity"),
}),
);
expect(execute).not.toHaveBeenCalled();
});
it("pins in-flight request identity beyond retention and independent of global dedupe pruning", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const release = createDeferredCore();
const { context, execute, invoke } = createFixture(async (request) => {
await release.promise;
request.respond(true, { key: "agent:main:finished" });
});
const original = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" });
await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS + 1;
context.dedupe.clear();
const conflicting = invoke({
agentId: "main",
idempotencyKey: "long-create",
message: "different",
});
await conflicting.done;
expect(conflicting.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
const joined = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" });
release.resolve();
await Promise.all([original.done, joined.done]);
expect(execute).toHaveBeenCalledOnce();
expect(joined.respond).toHaveBeenCalledWith(true, { key: "agent:main:finished" }, undefined, {
cached: true,
});
});
it("expires only settled successful results and immediately releases failed creates", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { execute, invoke } = createFixture();
await invoke().done;
now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS - 1;
await invoke().done;
expect(execute).toHaveBeenCalledOnce();
now += 1;
await invoke().done;
expect(execute).toHaveBeenCalledTimes(2);
let fail = true;
const failed = createFixture((request) => {
request.respond(!fail, fail ? undefined : { key: "recovered" });
fail = false;
});
await failed.invoke().done;
await failed.invoke().done;
expect(failed.execute).toHaveBeenCalledTimes(2);
});
it("enforces capacity per owner without evicting retained successful creations", async () => {
const { client, execute, invoke } = createFixture();
for (let index = 0; index < DEDUPE_MAX; index += 1) {
await invoke({ agentId: "main", idempotencyKey: `create-${index}` }).done;
}
const overflow = invoke({ agentId: "main", idempotencyKey: "overflow" });
await overflow.done;
expect(overflow.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "UNAVAILABLE", message: expect.stringContaining("retry") }),
);
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX);
const retained = invoke({ agentId: "main", idempotencyKey: "create-0" });
await retained.done;
expect(retained.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-0" }, undefined, {
cached: true,
});
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX);
const otherOwner = { ...client, authenticatedUserId: "other-owner" };
const otherCreation = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner);
await otherCreation.done;
expect(otherCreation.respond).toHaveBeenCalledWith(
true,
{ key: "agent:main:create-0" },
undefined,
undefined,
);
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1);
const otherReplay = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner);
await otherReplay.done;
expect(otherReplay.respond).toHaveBeenCalledWith(
true,
{ key: "agent:main:create-0" },
undefined,
{ cached: true },
);
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1);
for (let index = 1; index < DEDUPE_MAX; index += 1) {
await invoke({ agentId: "main", idempotencyKey: `create-${index}` }, otherOwner).done;
}
const thirdOwner = { ...client, authenticatedUserId: "third-owner" };
const processOverflow = invoke({ agentId: "main", idempotencyKey: "create-0" }, thirdOwner);
await processOverflow.done;
expect(processOverflow.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "UNAVAILABLE" }),
);
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX * 2);
});
});
@@ -0,0 +1,175 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import {
ErrorCodes,
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
errorShape,
missingScopeErrorShape,
} from "../../../packages/gateway-protocol/src/index.js";
import { DEDUPE_MAX } from "../server-constants.js";
import type { GatewayInflightResult } from "./inflight.js";
import type { GatewayRequestContext, GatewayRequestHandler } from "./types.js";
type SessionCreateAuthorization = { role: string | null; scopes: readonly string[] };
type SessionCreateEntry = {
requestIdentity: string;
authorization: SessionCreateAuthorization;
expiresAt: number;
state:
| { kind: "inflight"; work: Promise<GatewayInflightResult> }
| { kind: "completed"; result: GatewayInflightResult };
};
const sessionCreatesByContext = new WeakMap<
GatewayRequestContext,
Map<string, Map<string, SessionCreateEntry>>
>();
export function idempotentSessionCreate(handler: GatewayRequestHandler): GatewayRequestHandler {
return async (request) => {
const idempotencyKey = request.params.idempotencyKey;
if (typeof idempotencyKey !== "string" || !idempotencyKey) {
await handler(request);
return;
}
const principal =
request.client?.authenticatedUserProfile?.profileId ?? request.client?.authenticatedUserId;
const deviceId = request.client?.connect.device?.id?.trim();
if (!principal && !deviceId) {
request.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"idempotent session creation requires an authenticated principal or device identity",
),
);
return;
}
const owner = principal ? `principal:${principal}` : `device:${deviceId}`;
let entriesByOwner = sessionCreatesByContext.get(request.context);
if (!entriesByOwner) {
entriesByOwner = new Map();
sessionCreatesByContext.set(request.context, entriesByOwner);
}
const now = Date.now();
let retainedEntryCount = 0;
for (const [entryOwner, ownerEntries] of entriesByOwner) {
for (const [key, entry] of ownerEntries) {
if (entry.state.kind === "completed" && entry.expiresAt <= now) {
ownerEntries.delete(key);
}
}
if (ownerEntries.size === 0) {
entriesByOwner.delete(entryOwner);
} else {
retainedEntryCount += ownerEntries.size;
}
}
let entries = entriesByOwner.get(owner);
const requestIdentity = createHash("sha256")
.update(stableStringify(request.params))
.digest("hex");
const authorization: SessionCreateAuthorization = {
role: request.client?.connect.role ?? null,
scopes: request.client?.connect.scopes?.toSorted() ?? [],
};
const existing = entries?.get(idempotencyKey);
if (existing) {
if (existing.requestIdentity !== requestIdentity) {
request.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"session creation idempotency key was reused with different parameters",
),
);
return;
}
if (existing.authorization.role !== authorization.role) {
request.respond(
false,
undefined,
errorShape(ErrorCodes.FORBIDDEN, "session creation authorization changed; start again"),
);
return;
}
const missingScope = existing.authorization.scopes.find(
(scope) => !authorization.scopes.includes(scope),
);
if (missingScope) {
request.respond(
false,
undefined,
missingScopeErrorShape({
missingScope,
requiredScopes: existing.authorization.scopes,
}),
);
return;
}
const result =
existing.state.kind === "completed" ? existing.state.result : await existing.state.work;
request.respond(result.ok, result.payload, result.error, {
...result.meta,
cached: true,
});
return;
}
// Reserve a full owner's capacity for other principals while bounding process-wide state.
if ((entries?.size ?? 0) >= DEDUPE_MAX || retainedEntryCount >= DEDUPE_MAX * 2) {
request.respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "session creation capacity is full; retry later"),
);
return;
}
if (!entries) {
entries = new Map();
entriesByOwner.set(owner, entries);
}
const releaseEntry = () => {
entries.delete(idempotencyKey);
if (entries.size === 0) {
entriesByOwner.delete(owner);
}
};
// The entry is installed before work begins; in-flight identity is never TTL/cap-evictable.
const work = Promise.resolve().then(async (): Promise<GatewayInflightResult> => {
try {
let result: GatewayInflightResult | undefined;
await handler({
...request,
respond: (ok, payload, error, meta) => {
result = { ok, payload, error, meta };
},
});
result ??= {
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, "session creation was interrupted"),
};
if (result.ok) {
entry.expiresAt = Date.now() + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS;
entry.state = { kind: "completed", result };
} else {
releaseEntry();
}
return result;
} catch (error) {
releaseEntry();
throw error;
}
});
const entry: SessionCreateEntry = {
requestIdentity,
authorization,
expiresAt: now + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
state: { kind: "inflight", work },
};
entries.set(idempotencyKey, entry);
const result = await work;
request.respond(result.ok, result.payload, result.error, result.meta);
};
}
@@ -43,6 +43,7 @@ import { chatHandlers } from "./chat.js";
import { resolveRegisteredCatalogCreateTarget } from "./session-catalog.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { registerCreatedSessionCategory } from "./session-create-category.js";
import { idempotentSessionCreate } from "./session-create-idempotency.js";
import {
resolveSessionCreateInitialTurn,
shouldAttachPendingMessageSeq,
@@ -689,3 +690,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
}
},
};
sessionCreateHandlers["sessions.create"] = idempotentSessionCreate(
expectDefined(sessionCreateHandlers["sessions.create"], "sessions.create handler"),
);
+115
View File
@@ -4276,6 +4276,121 @@ test("sessions.create adopting an existing key does not restamp node provenance"
}
});
test("sessions.create replays an identical creation once and rejects conflicting intent", async () => {
await createSessionStoreDir();
const { chatHandlers } = await import("./server-methods/chat.js");
const { sessionCreateHandlers } = await import("./server-methods/sessions-create.js");
let sharedContext:
| Parameters<NonNullable<(typeof chatHandlers)["chat.send"]>>[0]["context"]
| undefined;
const chatSend = vi
.spyOn(chatHandlers, "chat.send")
.mockImplementation(async ({ context, respond }) => {
sharedContext ??= context;
respond(true, { runId: "create-once", status: "started" });
});
const dedupe = new Map();
const client = {
connect: {
role: "operator",
scopes: ["operator.write", "operator.admin"],
device: { id: "control-ui-device" },
},
authenticatedUserProfile: { profileId: "profile-owner" },
};
const params = {
agentId: "main",
idempotencyKey: "create-once",
message: "start this task exactly once",
permissionMode: "full",
};
const request = async (nextParams = params, nextClient = client) => {
if (!sharedContext) {
return await directSessionReq<{ key: string }>("sessions.create", nextParams, {
client: nextClient as never,
context: { dedupe },
});
}
let result:
| { ok: boolean; payload?: { key: string }; error?: { code?: string; message?: string } }
| undefined;
await sessionCreateHandlers["sessions.create"]?.({
req: {} as never,
params: nextParams,
client: nextClient as never,
context: sharedContext,
isWebchatConnect: () => false,
respond: (ok, payload, error) => {
result = { ok, payload: payload as { key: string } | undefined, error };
},
});
if (!result) {
throw new Error("sessions.create did not respond");
}
return result;
};
try {
const first = await request();
const replay = await request(
{
message: params.message,
permissionMode: params.permissionMode,
idempotencyKey: params.idempotencyKey,
agentId: params.agentId,
},
{
...client,
connect: {
...client.connect,
scopes: ["operator.admin", "operator.read", "operator.write"],
},
},
);
expect(first.ok).toBe(true);
expect(replay).toEqual(first);
expect(chatSend).toHaveBeenCalledOnce();
expect(chatSend.mock.calls[0]?.[0].params).toMatchObject({
idempotencyKey: expect.any(String),
message: "start this task exactly once",
});
const conflict = await request({ ...params, message: "start a different task" });
expect(conflict).toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "session creation idempotency key was reused with different parameters",
},
});
expect(chatSend).toHaveBeenCalledOnce();
const downgraded = await request(params, {
...client,
connect: { ...client.connect, scopes: ["operator.write"] },
});
expect(downgraded).toMatchObject({
ok: false,
error: { message: "missing scope: operator.admin" },
});
expect(chatSend).toHaveBeenCalledOnce();
const differentOwner = await request(params, {
...client,
authenticatedUserProfile: { profileId: "profile-other" },
});
expect(differentOwner.ok).toBe(true);
expect(differentOwner.payload?.key).not.toBe(first.payload?.key);
expect(chatSend).toHaveBeenCalledTimes(2);
expect(chatSend.mock.calls[1]?.[0].params.idempotencyKey).not.toBe(
chatSend.mock.calls[0]?.[0].params.idempotencyKey,
);
} finally {
chatSend.mockRestore();
}
});
test("sessions.create scopes the main alias to the requested agent", async () => {
const { storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "longmemeval" }] };
+12 -4
View File
@@ -224,6 +224,10 @@ export function renderSettingsToggleRow(props: {
`;
}
export function renderSettingsDefaultDescription(value: string, overridden: boolean) {
return html`${t(overridden ? "configForm.defaultValue" : "configForm.usingDefault", { value })}`;
}
export function renderSettingsDefaultState(props: {
value: string;
overridden: boolean;
@@ -234,10 +238,7 @@ export function renderSettingsDefaultState(props: {
action: TemplateResult | typeof nothing;
} {
return {
description: html`${t(
props.overridden ? "configForm.defaultValue" : "configForm.usingDefault",
{ value: props.value },
)}`,
description: renderSettingsDefaultDescription(props.value, props.overridden),
action: props.overridden
? html`
<button
@@ -263,6 +264,8 @@ export function renderSettingsSegmented<T extends string>(props: {
options: ReadonlyArray<{ value: T; label: unknown; title?: string; testId?: string }>;
/** The selected radio is passed so callers can anchor visual transitions. */
onChange: (value: T, element: HTMLElement) => void;
/** Optional activation for an already-selected value, such as clearing an explicit default. */
onReselect?: (value: T, element: HTMLElement) => void;
disabled?: boolean;
ariaLabel?: string;
className?: string;
@@ -299,6 +302,11 @@ export function renderSettingsSegmented<T extends string>(props: {
.checked=${live(option.value === props.value)}
title=${option.title ?? nothing}
data-test-id=${option.testId ?? nothing}
@click=${(event: Event) => {
if (option.value === props.value && event.currentTarget instanceof HTMLElement) {
props.onReselect?.(option.value, event.currentTarget);
}
}}
>
${option.label}
</wa-radio>
@@ -274,8 +274,7 @@ suite.define(() => {
await resetSyncedPreference({
click: () =>
themeSection
.locator(":scope > .settings-section__header")
.getByRole("button", { name: "Reset to default" })
.locator(".settings-theme-card--claw")
.click()
.then(() => undefined),
expectedKey: "theme",
@@ -289,7 +288,7 @@ suite.define(() => {
await resetSyncedPreference({
click: () =>
colorModeRow
.getByRole("button", { name: "Reset to default" })
.locator('wa-radio[value="system"]')
.click()
.then(() => undefined),
expectedKey: "themeMode",
@@ -298,10 +297,7 @@ suite.define(() => {
remainingPrefs: withoutThemeMode,
});
await textSizeSection
.locator(":scope > .settings-section__header")
.getByRole("button", { name: "Reset to default" })
.click();
await textSizeSection.locator(".settings-text-scale__btn", { hasText: "100%" }).click();
await expect.poll(() => readPersistedSettings(page)).not.toHaveProperty("textScale");
await resetSyncedPreference({
@@ -480,14 +476,16 @@ suite.define(() => {
}
await captureViewport(page, "09-accent-mint-selected.png");
await gateway.setMethodResponse(
"config.get",
configResponse({ accent: mintAccent, theme: "claw" }, "appearance-accent-3"),
);
await page.locator("#settings-appearance-theme .settings-theme-card--claw").click();
await waitForRequestCount(gateway, "config.patch", 2);
expect(patchPrefs((await gateway.getRequests("config.patch"))[1]!)).toEqual({
theme: "claw",
await resetSyncedPreference({
click: () =>
page
.locator("#settings-appearance-theme .settings-theme-card--claw")
.click()
.then(() => undefined),
expectedKey: "theme",
gateway,
hash: "appearance-accent-3",
remainingPrefs: { accent: mintAccent },
});
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("dark");
await expect.poll(() => readAccentPresentation(page)).toMatchObject({ accent: mintAccent });
@@ -495,7 +493,7 @@ suite.define(() => {
await gateway.setMethodResponse(
"config.get",
configResponse({ accent: customAccent, theme: "claw" }, "appearance-accent-4"),
configResponse({ accent: customAccent }, "appearance-accent-4"),
);
await accentSection.locator('input[type="color"][data-accent-custom]').fill(customAccent);
await waitForRequestCount(gateway, "config.patch", 3);
@@ -514,14 +512,13 @@ suite.define(() => {
await resetSyncedPreference({
click: () =>
accentSection
.locator(":scope > .settings-section__header")
.getByRole("button", { name: "Reset to default" })
.locator('[data-accent-preset="default"]')
.click()
.then(() => undefined),
expectedKey: "accent",
gateway,
hash: "appearance-accent-5",
remainingPrefs: { theme: "claw" },
remainingPrefs: {},
});
await expect
.poll(() =>
@@ -639,10 +636,7 @@ suite.define(() => {
.poll(() => followUpRow.textContent())
.toContain("Stocké uniquement dans ce navigateur");
await themeSection
.locator(":scope > .settings-section__header")
.locator("button.btn--icon")
.click();
await themeSection.locator(".settings-theme-card--claw").click();
await languageRow.locator("button.btn--icon").click();
await followUpRow.locator("button.btn--sm").click();
@@ -705,10 +699,7 @@ suite.define(() => {
await expect.poll(() => readPersistedSettings(page)).toMatchObject({ theme: "claw" });
expect(await gateway.getRequests("config.patch")).toHaveLength(0);
await themeSection
.locator(":scope > .settings-section__header")
.locator("button.btn--icon")
.click();
await themeSection.locator(".settings-theme-card--knot").click();
await expect
.poll(() => themeSection.locator(".settings-theme-card--knot").getAttribute("aria-pressed"))
.toBe("true");
@@ -8,6 +8,7 @@ import {
SOURCE_REPO,
TARGET_REPO,
WORKSPACE,
controlUiSessionPath,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
@@ -400,9 +401,9 @@ suite.define(() => {
});
for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) {
it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => {
it(`automatically resumes an idempotent session creation after ${reconnectKind}`, async () => {
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`;
const sessionKey = `agent:main:resumed-${reconnectKind.replaceAll(" ", "-")}`;
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": mainAgentList("Original agent", SOURCE_REPO),
@@ -413,12 +414,14 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new`);
await page.getByRole("heading", { name: "Original agent" }).waitFor();
const message = page.locator(".new-session-page__message");
const projectTrigger = page.locator("#new-session-project-trigger");
const start = page.locator("button.chat-send-btn");
await message.fill("retry this draft after reconnect");
await gateway.deferNext("sessions.create");
await start.click();
await gateway.waitForRequest("sessions.create");
const originalCreate = await gateway.waitForRequest("sessions.create");
await expect.poll(() => start.isDisabled()).toBe(true);
await gateway.deferNext("sessions.create");
if (reconnectKind === "client replacement") {
await gateway.setMethodResponse(
@@ -433,30 +436,67 @@ suite.define(() => {
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
await gateway.setOnline(false);
await waitForControlUiGatewayReconnecting(page);
expect(await message.isDisabled()).toBe(true);
expect(await projectTrigger.isDisabled()).toBe(true);
expect(await start.getAttribute("aria-busy")).toBe("true");
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
.toBe(agentRequestsBefore + 1);
}
await expect.poll(() => message.inputValue()).toBe("retry this draft after reconnect");
await expect.poll(() => message.isEnabled()).toBe(true);
await expect.poll(() => start.isDisabled()).toBe(true);
// The gate table also surfaces this reason in the Start tooltip, so
// scope to the page callout instead of a bare text match.
await page
.getByRole("alert")
.filter({
hasText:
"The Gateway changed while this session was starting. Check recent sessions before starting this task again.",
})
.waitFor();
expect(new URL(page.url()).pathname).toBe("/new");
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
await expect
.poll(async () => (await gateway.getRequests("sessions.create")).length)
.toBe(2);
const resumedCreate = (await gateway.getRequests("sessions.create")).at(-1);
expect(originalCreate.params).toMatchObject({
idempotencyKey: expect.any(String),
message: "retry this draft after reconnect",
});
expect(resumedCreate?.params).toEqual(originalCreate.params);
expect(await message.inputValue()).toBe("retry this draft after reconnect");
expect(await message.isDisabled()).toBe(true);
expect(await projectTrigger.isDisabled()).toBe(true);
await gateway.resolveDeferred("sessions.create", { key: sessionKey });
await gateway.resolveDeferred("sessions.create", { key: sessionKey });
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey));
});
});
}
it("keeps an interrupted creation fail-closed after the Gateway process restarts", async () => {
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": mainAgentList(),
"worktrees.branches": branchList(),
},
});
await page.goto(`${suite.server.baseUrl}new`);
await page.getByRole("heading", { name: "Main" }).waitFor();
await page.locator(".new-session-page__message").fill("do not duplicate this task");
await gateway.deferNext("sessions.create");
await page.getByRole("button", { name: "Start session" }).click();
await gateway.waitForRequest("sessions.create");
await gateway.setOnline(false);
await waitForControlUiGatewayReconnecting(page);
await gateway.setGatewayBootId("different-gateway-process");
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await page
.getByRole("alert")
.filter({
hasText:
"The Gateway changed while this session was starting. Check recent sessions before starting this task again.",
})
.waitFor();
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
expect(new URL(page.url()).pathname).toBe("/new");
});
});
it("resets agent-derived workspace state when retargeted to a catalog", async () => {
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
@@ -575,13 +575,18 @@ suite.define(() => {
await card.waitFor({ state: "visible" });
const firstBounds = await first.boundingBox();
expect(firstBounds).not.toBeNull();
const cardBounds = await card.boundingBox();
expect(cardBounds).not.toBeNull();
await pointer(first, "pointerout", {
clientX: (firstBounds?.x ?? 0) + (firstBounds?.width ?? 0),
clientY: (firstBounds?.y ?? 0) + (firstBounds?.height ?? 0) / 2,
});
await page.waitForTimeout(150);
expect(await card.count()).toBe(1);
await card.hover();
await page.mouse.move(
(cardBounds?.x ?? 0) + (cardBounds?.width ?? 0) / 2,
(cardBounds?.y ?? 0) + (cardBounds?.height ?? 0) / 2,
);
expect(await card.count()).toBe(1);
await page.mouse.move(900, 800);
await page.waitForTimeout(50);
+6 -2
View File
@@ -31,7 +31,9 @@ const textTokens = [
const surfaceTokens = ["--bg", "--bg-elevated", "--bg-muted", "--card", "--panel"] as const;
function themeConfigResponse(family: "claw" | "knot" | "dash", mode: "dark" | "light") {
const config = { ui: { prefs: { theme: family, themeMode: mode } } };
const config = {
ui: { prefs: { ...(family === "claw" ? {} : { theme: family }), themeMode: mode } },
};
const hash = `theme-contrast-${family}-${mode}`;
return {
appliedConfigHash: hash,
@@ -176,7 +178,9 @@ suite.define(() => {
const patch = await gateway.waitForRequest("config.patch");
const raw = (patch.params as { raw?: unknown } | undefined)?.raw;
expect(typeof raw).toBe("string");
expect(JSON.parse(String(raw))).toMatchObject({ ui: { prefs: { theme: family } } });
expect(JSON.parse(String(raw))).toMatchObject({
ui: { prefs: { theme: family === "claw" ? null : family } },
});
// Theme clicks apply immediately; the eventual Gateway acknowledgement must not revert them.
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe(resolved);
+6 -30
View File
@@ -1,4 +1,7 @@
import type { SessionsCreateResult } from "../../../../packages/gateway-protocol/src/index.js";
import type {
SessionsCreateParams,
SessionsCreateResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
export type SessionCreateOutcome = {
@@ -9,35 +12,8 @@ export type SessionCreateOutcome = {
| { status: "rejected"; error: string };
};
export type SessionCreateParams = {
key?: string;
agentId?: string;
catalogId?: string;
export type SessionCreateParams = SessionsCreateParams & {
currentSessionKey?: string;
parentSessionKey?: string;
fork?: boolean;
forkFrom?: "last-completed";
succeedsParent?: boolean;
label?: string;
category?: string;
model?: string;
contextWindow?: string;
thinkingLevel?: string;
incognito?: boolean;
worktree?: boolean;
/** Base ref for the managed worktree branch; requires worktree. */
worktreeBaseRef?: string;
/** Worktree name (branch becomes openclaw/<name>); requires worktree. */
worktreeName?: string;
/** Bind session exec to host=node with this node id (operator.admin). */
execNode?: string;
/** Absolute source checkout for the worktree (operator.admin). */
cwd?: string;
/** First message; the gateway creates the session and starts the run in one call. */
message?: string;
/** Attachments for the first message, using the chat.send wire format. */
attachments?: unknown[];
task?: string;
};
export function resolveSessionCreateParams(sessionKey = "", agentId?: string) {
@@ -56,7 +32,7 @@ export function resolveSessionCreateParams(sessionKey = "", agentId?: string) {
export async function requestSessionCreate(
client: Pick<GatewayBrowserClient, "request">,
params: Omit<SessionCreateParams, "currentSessionKey"> & { emitCommandHooks?: boolean } = {},
params: Omit<SessionCreateParams, "currentSessionKey"> = {},
): Promise<SessionCreateOutcome> {
const result = await client.request<SessionsCreateResult>("sessions.create", params);
const key = typeof result?.key === "string" ? result.key.trim() : "";
+1 -5
View File
@@ -201,11 +201,7 @@ describe("ConfigPage synced preference provenance", () => {
expect(themeSection?.textContent).not.toContain("Default: Knot");
expect(themeSection?.textContent).not.toContain("Stored in this browser only");
themeSection
?.querySelector<HTMLButtonElement>(
":scope > .settings-section__header button[aria-label='Reset to default']",
)
?.click();
themeSection?.querySelector<HTMLButtonElement>(".settings-theme-card--claw")?.click();
expect(changedServerUiPrefs(beforeReset, state.settings)).toEqual({ theme: null });
});
+20 -10
View File
@@ -938,7 +938,6 @@ export class ConfigPage extends OpenClawLightDomElement {
) {
switch (key) {
case "theme":
this.customThemeImportOwner.recordActivation(null);
this.settings = resetServerUiPref(
"theme",
this.currentThemePref(),
@@ -981,13 +980,16 @@ export class ConfigPage extends OpenClawLightDomElement {
theme: ThemeName,
context?: Parameters<typeof startThemeTransition>[0]["context"],
) {
this.customThemeImportOwner.recordActivation(theme);
const preference = this.currentThemePref();
const reset = preference.overridden && theme === preference.resetValue;
this.customThemeImportOwner.recordActivation(reset ? null : theme);
const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode);
startThemeTransition({
currentTheme,
nextTheme: resolveTheme(theme, this.settings.themeMode),
context,
applyTheme: () => this.applySettings({ theme }),
applyTheme: () =>
reset ? this.resetSyncedAppearancePref("theme") : this.applySettings({ theme }),
});
}
@@ -995,12 +997,17 @@ export class ConfigPage extends OpenClawLightDomElement {
mode: ThemeMode,
context?: Parameters<typeof startThemeTransition>[0]["context"],
) {
const preference = this.currentThemeModePref();
const reset = preference.overridden && mode === preference.resetValue;
const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode);
startThemeTransition({
currentTheme,
nextTheme: resolveTheme(this.settings.theme, mode),
context,
applyTheme: () => this.applySettings({ themeMode: mode }),
applyTheme: () =>
reset
? this.resetSyncedAppearancePref("themeMode")
: this.applySettings({ themeMode: mode }),
});
}
@@ -1248,11 +1255,11 @@ export class ConfigPage extends OpenClawLightDomElement {
onLocaleChange: (locale) => this.setLocale(locale),
resetLocale: () => this.resetLocale(),
setTheme: (theme, transitionContext) => this.setTheme(theme, transitionContext),
resetTheme: () => this.resetSyncedAppearancePref("theme"),
setThemeMode: (mode, transitionContext) => this.setThemeMode(mode, transitionContext),
resetThemeMode: () => this.resetSyncedAppearancePref("themeMode"),
setAccent: (accent) => this.applySettings({ accent }),
resetAccent: () => this.resetSyncedAppearancePref("accent"),
setAccent: (accent) =>
accent === undefined
? this.resetSyncedAppearancePref("accent")
: this.applySettings({ accent }),
hasCustomTheme: Boolean(this.settings.customTheme),
customThemeLabel: this.settings.customTheme?.label ?? null,
customThemeSourceUrl: this.settings.customTheme?.sourceUrl ?? null,
@@ -1267,8 +1274,11 @@ export class ConfigPage extends OpenClawLightDomElement {
onOpenCustomThemeImport: () => this.customThemeImportOwner.open(),
textScale: this.settings.textScale ?? UI_APPEARANCE_DEFAULTS.textScale,
textScaleOverridden: this.settings.textScale !== undefined,
setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)),
resetTextScale: () => this.setSetting("textScale", undefined),
setTextScale: (value) =>
this.setSetting(
"textScale",
value === UI_APPEARANCE_DEFAULTS.textScale ? undefined : normalizeTextScale(value),
),
sidebarLiveActivity:
this.settings.sidebarLiveActivity ?? UI_APPEARANCE_DEFAULTS.sidebarLiveActivity,
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
+47 -51
View File
@@ -10,7 +10,7 @@ import type { ThemeName } from "../../app/theme.ts";
import { icons } from "../../components/icons.ts";
import {
renderDocsLink,
renderSettingsDefaultState,
renderSettingsDefaultDescription,
renderSettingsRow,
renderSettingsSegmented,
renderSettingsStatus,
@@ -148,39 +148,21 @@ export function renderAppearanceSection(
: t("configView.appearance.importHint"),
},
];
const themeDefaultState = renderSettingsDefaultState({
value:
themeOptions.find((option) => option.id === props.themeResetValue)?.label ??
t("configView.themes.claw.label"),
overridden: props.themeOverridden,
onReset: props.resetTheme,
});
const themeModeDefaultState = renderSettingsDefaultState({
value:
props.themeModeResetValue === "light"
? t("common.light")
: props.themeModeResetValue === "dark"
? t("common.dark")
: t("common.system"),
overridden: props.themeModeOverridden,
onReset: props.resetThemeMode,
});
const accentDefaultState = renderSettingsDefaultState({
value: t("configView.appearance.accents.default"),
overridden: props.accentOverridden,
onReset: props.resetAccent,
});
const themeDefault =
themeOptions.find((option) => option.id === props.themeResetValue)?.label ??
t("configView.themes.claw.label");
const themeModeDefault =
props.themeModeResetValue === "light"
? t("common.light")
: props.themeModeResetValue === "dark"
? t("common.dark")
: t("common.system");
const themeProvenance = serverUiPrefProvenanceHint(props.themeProvenance);
const themeModeProvenance = serverUiPrefProvenanceHint(props.themeModeProvenance);
const accentProvenance = serverUiPrefProvenanceHint(props.accentProvenance);
const customAccentSelected = Boolean(
props.accent && !ACCENT_PRESETS.some((preset) => preset.hex === props.accent),
);
const textScaleDefaultState = renderSettingsDefaultState({
value: `${UI_APPEARANCE_DEFAULTS.textScale}%`,
overridden: props.textScaleOverridden,
onReset: props.resetTextScale,
});
return html`
<div class="settings-page">
<p class="settings-page__intro">
@@ -191,10 +173,10 @@ export function renderAppearanceSection(
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.theme} class="settings-section">
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.appearance.theme")}</h2>
<div class="settings-section__actions">${themeDefaultState.action}</div>
</div>
<p class="settings-section__desc">
${t("configView.appearance.chooseTheme")} ${themeDefaultState.description}
${t("configView.appearance.chooseTheme")}
${renderSettingsDefaultDescription(themeDefault, props.themeOverridden)}
${themeProvenance}
</p>
<div class="settings-group">
@@ -216,7 +198,10 @@ export function renderAppearanceSection(
props.onOpenCustomThemeImport?.();
return;
}
if (opt.id !== props.theme) {
if (
opt.id !== props.theme ||
(opt.id === props.themeResetValue && props.themeOverridden)
) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
@@ -238,21 +223,27 @@ export function renderAppearanceSection(
</div>
${renderSettingsRow({
title: t("common.colorMode"),
description: html`${themeModeDefaultState.description} ${themeModeProvenance}`,
description: html`${renderSettingsDefaultDescription(
themeModeDefault,
props.themeModeOverridden,
)}
${themeModeProvenance}`,
stacked: true,
control: html`
${themeModeDefaultState.action}
${renderSettingsSegmented({
value: props.themeMode,
options: [
{ value: "system", label: t("common.system") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
],
ariaLabel: t("common.colorMode"),
onChange: (mode, element) => props.setThemeMode(mode, { element }),
})}
`,
control: renderSettingsSegmented({
value: props.themeMode,
options: [
{ value: "system", label: t("common.system") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
],
ariaLabel: t("common.colorMode"),
onChange: (mode, element) => props.setThemeMode(mode, { element }),
onReselect: (mode, element) => {
if (props.themeModeOverridden && mode === props.themeModeResetValue) {
props.setThemeMode(mode, { element });
}
},
}),
})}
<div class="settings-row settings-row--stacked">
${showCustomThemeImport
@@ -337,10 +328,13 @@ export function renderAppearanceSection(
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.accent} class="settings-section">
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.appearance.accent")}</h2>
<div class="settings-section__actions">${accentDefaultState.action}</div>
</div>
<p class="settings-section__desc">
${t("configView.appearance.accentHint")} ${accentDefaultState.description}
${t("configView.appearance.accentHint")}
${renderSettingsDefaultDescription(
t("configView.appearance.accents.default"),
props.accentOverridden,
)}
${accentProvenance}
</p>
<div class="settings-group">
@@ -368,8 +362,7 @@ export function renderAppearanceSection(
aria-label=${label}
aria-pressed=${String(selected)}
title=${label}
@click=${() =>
preset.hex === undefined ? props.resetAccent() : props.setAccent(preset.hex)}
@click=${() => props.setAccent(preset.hex)}
>
${selected
? html`<span class="settings-accent-swatch__check" aria-hidden="true"
@@ -399,10 +392,13 @@ export function renderAppearanceSection(
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.textSize} class="settings-section">
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.appearance.textSize")}</h2>
<div class="settings-section__actions">${textScaleDefaultState.action}</div>
</div>
<p class="settings-section__desc">
${textScaleDefaultState.description} ${t("quickSettings.personal.browserOnly")}
${renderSettingsDefaultDescription(
`${UI_APPEARANCE_DEFAULTS.textScale}%`,
props.textScaleOverridden,
)}
${t("quickSettings.personal.browserOnly")}
</p>
<div class="settings-group">
<div class="settings-row settings-row--stacked">
+1 -5
View File
@@ -115,11 +115,8 @@ export type ConfigProps = {
onLocaleChange: (locale: Locale | undefined) => void;
resetLocale: () => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
resetTheme: () => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
resetThemeMode: () => void;
setAccent: (accent: string) => void;
resetAccent: () => void;
setAccent: (accent: string | undefined) => void;
hasCustomTheme: boolean;
customThemeLabel: string | null;
customThemeSourceUrl: string | null;
@@ -135,7 +132,6 @@ export type ConfigProps = {
textScale: number;
textScaleOverridden: boolean;
setTextScale: (value: number) => void;
resetTextScale: () => void;
sidebarLiveActivity: boolean;
setSidebarLiveActivity: (enabled: boolean) => void;
hiddenSessionCatalogIds: ReadonlySet<string>;
+37 -47
View File
@@ -67,11 +67,8 @@ describe("config view", () => {
onLocaleChange: vi.fn(),
resetLocale: vi.fn(),
setTheme: vi.fn(),
resetTheme: vi.fn(),
setThemeMode: vi.fn(),
resetThemeMode: vi.fn(),
setAccent: vi.fn(),
resetAccent: vi.fn(),
hasCustomTheme: false,
customThemeLabel: null,
customThemeSourceUrl: null,
@@ -87,7 +84,6 @@ describe("config view", () => {
textScale: 100,
textScaleOverridden: false,
setTextScale: vi.fn(),
resetTextScale: vi.fn(),
sidebarLiveActivity: true,
setSidebarLiveActivity: vi.fn(),
hiddenSessionCatalogIds: new Set<string>(),
@@ -1642,14 +1638,12 @@ describe("config view", () => {
expect(container.querySelector('button[aria-label="Reset to default"]')).toBeNull();
});
it("resets every explicit Appearance override independently", () => {
it("keeps direct Appearance defaults while resetting unrelated overrides independently", () => {
const resetLocale = vi.fn();
const setTheme = vi.fn();
const resetTheme = vi.fn();
const setThemeMode = vi.fn();
const resetThemeMode = vi.fn();
const setAccent = vi.fn();
const setTextScale = vi.fn();
const resetTextScale = vi.fn();
const setSidebarLiveActivity = vi.fn();
const setChatMessageMaxWidth = vi.fn();
const setChatSendShortcut = vi.fn();
@@ -1668,15 +1662,15 @@ describe("config view", () => {
theme: "knot",
themeOverridden: true,
setTheme,
resetTheme,
themeMode: "dark",
themeModeOverridden: true,
setThemeMode,
resetThemeMode,
accent: "#52c99a",
accentOverridden: true,
setAccent,
textScale: 110,
textScaleOverridden: true,
setTextScale,
resetTextScale,
sidebarLiveActivity: false,
setSidebarLiveActivity,
chatMessageMaxWidth: "82%",
@@ -1707,10 +1701,21 @@ describe("config view", () => {
candidate.querySelector(".settings-row__title")?.textContent?.trim() === title,
) ?? null;
findButtonByText(container, "Claw").click();
const colorModeGroup = row("Color mode")?.querySelector<HTMLElement & { value: string }>(
"wa-radio-group",
);
expect(colorModeGroup).toBeDefined();
if (colorModeGroup) {
colorModeGroup.value = "system";
colorModeGroup.dispatchEvent(new Event("change", { bubbles: true }));
}
container.querySelector<HTMLButtonElement>('[data-accent-preset="default"]')?.click();
Array.from(container.querySelectorAll<HTMLButtonElement>(".settings-text-scale__btn"))
.find((button) => button.textContent?.includes("100%"))
?.click();
resetIn(container.querySelector("#settings-language .settings-row"));
resetIn(container.querySelector("#settings-appearance-theme > .settings-section__header"));
resetIn(row("Color mode"));
resetIn(container.querySelector("#settings-appearance-text-size > .settings-section__header"));
resetIn(row("Show live agent activity in sidebar"));
resetIn(row("Message width"));
resetIn(row("Send shortcut"));
@@ -1720,9 +1725,10 @@ describe("config view", () => {
resetIn(row("Lobster sounds"));
expect(resetLocale).toHaveBeenCalledOnce();
expect(resetTheme).toHaveBeenCalledOnce();
expect(resetThemeMode).toHaveBeenCalledOnce();
expect(resetTextScale).toHaveBeenCalledOnce();
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
expect(setThemeMode).toHaveBeenCalledWith("system", expect.any(Object));
expect(setAccent).toHaveBeenCalledWith(undefined);
expect(setTextScale).toHaveBeenCalledWith(100);
expect(setSidebarLiveActivity).toHaveBeenCalledWith(true);
expect(setChatMessageMaxWidth).toHaveBeenCalledWith(undefined);
expect(resetChatSendShortcut).toHaveBeenCalledOnce();
@@ -1732,9 +1738,9 @@ describe("config view", () => {
expect(setLobsterPetSounds).toHaveBeenCalledWith(false);
});
it("shows reset actions for authored synced values equal to product defaults", () => {
const resetTheme = vi.fn();
const resetThemeMode = vi.fn();
it("keeps authored visual defaults direct while preserving chat preference resets", () => {
const setTheme = vi.fn();
const setThemeMode = vi.fn();
const resetChatSendShortcut = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
@@ -1742,11 +1748,11 @@ describe("config view", () => {
theme: "claw",
themeOverridden: true,
themeProvenance: "synced",
resetTheme,
setTheme,
themeMode: "system",
themeModeOverridden: true,
themeModeProvenance: "synced",
resetThemeMode,
setThemeMode,
chatSendShortcut: "enter",
chatSendShortcutOverridden: true,
chatSendShortcutProvenance: "synced",
@@ -1761,30 +1767,18 @@ describe("config view", () => {
expect(normalizedText(themeSection)).toContain("Default: Claw");
expect(normalizedText(themeSection)).toContain("Default: System");
expect(shortcutRow?.textContent).toContain("Default: Enter");
themeSection
.querySelector<HTMLButtonElement>(
":scope > .settings-section__header button[aria-label='Reset to default']",
)
?.click();
const colorModeRow = Array.from(
themeSection.querySelectorAll<HTMLElement>(".settings-row"),
).find(
(candidate) =>
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "Color mode",
);
colorModeRow
?.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']")
?.click();
findButtonByText(themeSection, "Claw").click();
themeSection.querySelector<HTMLElement>('wa-radio[value="system"]')?.click();
shortcutRow?.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']")?.click();
expect(resetTheme).toHaveBeenCalledOnce();
expect(resetThemeMode).toHaveBeenCalledOnce();
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
expect(setThemeMode).toHaveBeenCalledWith("system", expect.any(Object));
expect(resetChatSendShortcut).toHaveBeenCalledOnce();
});
it("renders rejected theme and locale edits as resettable browser-only fallbacks", () => {
it("renders rejected theme and locale edits as browser-only fallbacks", () => {
const resetLocale = vi.fn();
const resetTheme = vi.fn();
const setTheme = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
@@ -1797,7 +1791,7 @@ describe("config view", () => {
themeOverridden: true,
themeProvenance: "device-local",
themeResetValue: "claw",
resetTheme,
setTheme,
});
const languageRow = queryRequired(container, "#settings-language .settings-row", HTMLElement);
const themeSection = queryRequired(container, "#settings-appearance-theme", HTMLElement);
@@ -1825,14 +1819,10 @@ describe("config view", () => {
).toBe("true");
languageRow.querySelector<HTMLButtonElement>('button[aria-label="Reset to default"]')?.click();
themeSection
.querySelector<HTMLButtonElement>(
":scope > .settings-section__header button[aria-label='Reset to default']",
)
?.click();
findButtonByText(themeSection, "Claw").click();
expect(resetLocale).toHaveBeenCalledOnce();
expect(resetTheme).toHaveBeenCalledOnce();
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
});
it("shows pending synced preferences without claiming they already synced", () => {
+3 -2
View File
@@ -1,4 +1,5 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -32,7 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
contextWindow?: string;
thinkingLevel?: string;
visibility?: NewSessionVisibility;
attachments?: unknown[];
attachments?: SessionCreateParams["attachments"];
projectId?: string;
worktree: boolean;
baseRef?: string;
@@ -41,7 +42,7 @@ export function buildDraftSessionCreateParams(draft: {
workspace?: string;
catalogId?: string;
category?: string;
}): Record<string, unknown> {
}): SessionCreateParams {
const cwd = normalizeOptionalString(draft.cwd);
const workspace = normalizeOptionalString(draft.workspace);
const catalogId = normalizeOptionalString(draft.catalogId);
@@ -73,6 +73,7 @@ export class DraftGatewayState {
private gatewaySource: ApplicationContext["gateway"] | null = null;
private gatewayClientValue: ApplicationContext["gateway"]["snapshot"]["client"] = null;
private gatewayUrlValue = "";
private gatewayBootIdValue = "";
private gatewayRecoveryScopeValue = "";
private gatewayRecoveryScopeReady = false;
private gatewayConnectedValue = false;
@@ -178,6 +179,11 @@ export class DraftGatewayState {
return this.gatewayRecoveryScopeValue;
}
get sessionCreateScope(): string {
const scope = [this.gatewayUrlValue, this.gatewayRecoveryScopeValue, this.gatewayBootIdValue];
return scope.every(Boolean) ? JSON.stringify(scope) : "";
}
get connected(): boolean {
return this.gatewayConnectedValue;
}
@@ -208,6 +214,15 @@ export class DraftGatewayState {
const snapshot = gateway.snapshot;
const connected = snapshot.phase === "connected";
const firstBind = this.gatewaySource === null;
// The Gateway's idempotency ledger is process-local; a new boot cannot safely replay a start.
const bootId = connected
? (snapshot.hello?.server?.bootId?.trim() ?? "")
: this.gatewayBootIdValue;
const gatewayBootChanged =
!firstBind &&
connected &&
Boolean(this.gatewayBootIdValue) &&
bootId !== this.gatewayBootIdValue;
const gatewayUrlChanged = !firstBind && this.gatewayUrlValue !== gateway.connection.gatewayUrl;
const gatewaySourceChanged = !firstBind && this.gatewaySource !== gateway;
const identityChanged =
@@ -224,13 +239,20 @@ export class DraftGatewayState {
this.gatewaySource = gateway;
this.gatewayClientValue = snapshot.client;
this.gatewayUrlValue = gateway.connection.gatewayUrl;
this.gatewayBootIdValue = bootId;
this.gatewayRecoveryScopeValue = recoveryScope.next;
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
this.gatewayConnectedValue = connected;
if (this.read().visibility === "draft" && !this.read().canStartAsDraft) {
this.callbacks.onVisibilityRetired();
}
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
if (
gatewayUrlChanged ||
gatewayBootChanged ||
identityChanged ||
connectionChanged ||
recoveryScope.changed
) {
const ownerChanged = gatewaySourceChanged || gatewayUrlChanged || recoveryScope.changed;
const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed;
this.invalidateDiscovery(
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
import { DraftSessionStartup } from "./draft-session-startup.ts";
afterEach(() => {
vi.restoreAllMocks();
});
function createStartup() {
const gateway = { connected: true, sessionCreateScope: "gateway:principal:boot-a" };
const startup = new DraftSessionStartup(gateway);
const params = startup.start({ agentId: "main", message: "preserve this task" });
return { gateway, params, startup };
}
describe("DraftSessionStartup", () => {
it("resumes the exact frozen direct-create intent once on the same Gateway scope", () => {
const { params, startup } = createStartup();
expect(params).toMatchObject({
agentId: "main",
idempotencyKey: expect.any(String),
message: "preserve this task",
});
expect(Object.isFrozen(params)).toBe(true);
expect(startup.interrupt()).toBe(true);
const resumed = startup.resume();
expect(resumed).toMatchObject({ kind: "resume", params, startedAt: expect.any(Number) });
if (resumed.kind === "resume") {
expect(resumed.params).toBe(params);
}
expect(startup.resume()).toEqual({ kind: "wait" });
});
it("fails closed and unlocks when the Gateway scope or process boot changes", () => {
const { gateway, startup } = createStartup();
startup.interrupt();
gateway.sessionCreateScope = "gateway:principal:boot-b";
expect(startup.resume()).toEqual({ kind: "owner-changed" });
expect(startup.active).toBe(false);
});
it("waits for reconnection without consulting mutable draft selections", () => {
const { gateway, params, startup } = createStartup();
startup.interrupt();
gateway.connected = false;
expect(startup.resume()).toEqual({ kind: "wait" });
expect(startup.active).toBe(true);
gateway.connected = true;
expect(startup.resume()).toMatchObject({ kind: "resume", params });
});
it("expires and unlocks at the shared client retry deadline", () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const { startup } = createStartup();
startup.interrupt();
now += SESSION_CREATE_RETRY_WINDOW_MS;
expect(startup.resume()).toEqual({ kind: "expired" });
expect(startup.active).toBe(false);
});
});
@@ -0,0 +1,90 @@
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
import { generateUUID } from "../../lib/uuid.ts";
import type { DraftGatewayState } from "./draft-gateway-state.ts";
type DraftSessionStartupIntent = {
params: SessionCreateParams;
scope: string;
startedAt: number;
deadline: number;
interrupted: boolean;
};
type DraftSessionStartupResume =
| { kind: "wait" | "expired" | "owner-changed" }
| { kind: "resume"; params: SessionCreateParams; startedAt: number };
export class DraftSessionStartup {
private pending: DraftSessionStartupIntent | null = null;
constructor(
private readonly gateway: Pick<DraftGatewayState, "connected" | "sessionCreateScope">,
) {}
get active(): boolean {
return this.pending !== null;
}
start(params: SessionCreateParams): SessionCreateParams {
const scope = this.gateway.sessionCreateScope;
if (!scope) {
return params;
}
if (!this.pending) {
const startedAt = Date.now();
this.pending = {
params: Object.freeze({ ...params, idempotencyKey: generateUUID() }),
scope,
startedAt,
deadline: startedAt + SESSION_CREATE_RETRY_WINDOW_MS,
interrupted: false,
};
}
return this.pending.params;
}
clear() {
this.pending = null;
}
interrupt(): boolean {
if (!this.pending || !this.matchesGateway()) {
this.clear();
return false;
}
this.pending.interrupted = true;
return true;
}
retireChangedOwner(): boolean {
if (!this.pending || this.matchesGateway()) {
return false;
}
this.clear();
return true;
}
resume(): DraftSessionStartupResume {
if (!this.pending?.interrupted) {
return { kind: "wait" };
}
if (Date.now() >= this.pending.deadline) {
this.clear();
return { kind: "expired" };
}
if (!this.matchesGateway()) {
this.clear();
return { kind: "owner-changed" };
}
if (!this.gateway.connected) {
return { kind: "wait" };
}
this.pending.interrupted = false;
return { kind: "resume", params: this.pending.params, startedAt: this.pending.startedAt };
}
private matchesGateway(): boolean {
return this.pending?.scope === this.gateway.sessionCreateScope;
}
}
@@ -1,5 +1,6 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
import type { ApplicationContext } from "../../app/context.ts";
import { CHAT_ROUTE_READY_EVENT } from "../../app/route-transition.ts";
import { writeSessionPlacementRecovery } from "../../lib/sessions/session-placement-recovery.ts";
@@ -29,6 +30,7 @@ class ControllerHost implements ReactiveControllerHost {
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
@@ -64,6 +66,7 @@ function createDraftFixture(options: FixtureOptions = {}) {
hello:
phase === "connected"
? {
server: { bootId: "gateway-boot-a" },
auth: {
role: "operator",
scopes: options.scopes ?? ["operator.read", "operator.write"],
@@ -362,6 +365,95 @@ describe("DraftSubmissionFlow submit gates", () => {
});
describe("DraftSubmissionFlow", () => {
it("replays a frozen direct create without inheriting refreshed placement or mutable submit gates", async () => {
const { context, flow, place } = createDraftFixture({
methods: ["sessions.create", "sessions.dispatch"],
scopes: ["operator.admin", "operator.read", "operator.write"],
});
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
const original = new Promise<{ key: string; initialRun: { status: "idle" } }>((resolve) => {
finishOriginal = resolve;
});
const result = { key: "agent:main:direct-resumed", initialRun: { status: "idle" as const } };
vi.mocked(context.sessions.createResult)
.mockImplementationOnce(() => original)
.mockResolvedValueOnce(result);
vi.mocked(context.navigateAndWait).mockImplementation(async () => {
queueMicrotask(() => document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT)));
});
flow.setMessage("keep the original direct request");
const initialSubmission = flow.submit();
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
const originalParams = vi.mocked(context.sessions.createResult).mock.calls[0]?.[0];
flow.invalidate("gateway-changed");
place.applyPendingPlacement({ agentId: "main", profileId: "new-cloud-discovery" });
expect(flow.canSubmit()).toBe(false);
expect(flow.submitting).toBe(true);
flow.resumeInterruptedSubmission();
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledTimes(2));
expect(vi.mocked(context.sessions.createResult).mock.calls[1]?.[0]).toEqual(originalParams);
expect(flow.pendingPlacement.sessionKey).toBe("");
finishOriginal(result);
await initialSubmission;
await vi.waitFor(() => expect(flow.submitting).toBe(false));
});
it("unlocks visibly when a frozen retry loses sessions.create access", async () => {
const { context, flow } = createDraftFixture();
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
vi.mocked(context.sessions.createResult).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOriginal = resolve;
}),
);
flow.setMessage("do not replay without authority");
const initialSubmission = flow.submit();
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
flow.invalidate("gateway-changed");
if (context.gateway.snapshot.hello?.features) {
context.gateway.snapshot.hello.features.methods = [];
}
flow.resumeInterruptedSubmission();
expect(flow.error).toBeTruthy();
expect(flow.submitting).toBe(false);
expect(context.sessions.createResult).toHaveBeenCalledOnce();
finishOriginal({ key: "agent:main:old", initialRun: { status: "idle" } });
await initialSubmission;
});
it("expires an interrupted direct create and unlocks with an explicit unknown outcome", async () => {
const clock = vi.spyOn(Date, "now");
let now = 1_000;
clock.mockImplementation(() => now);
const { context, flow } = createDraftFixture();
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
vi.mocked(context.sessions.createResult).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOriginal = resolve;
}),
);
flow.setMessage("the original outcome is unknown");
const initialSubmission = flow.submit();
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
flow.invalidate("gateway-changed");
now += SESSION_CREATE_RETRY_WINDOW_MS;
flow.resumeInterruptedSubmission();
expect(flow.submissionOutcomeUnknown).toBe("gateway-changed");
expect(flow.submitting).toBe(false);
expect(context.sessions.createResult).toHaveBeenCalledOnce();
finishOriginal({ key: "agent:main:old", initialRun: { status: "idle" } });
await initialSubmission;
clock.mockRestore();
});
it("surfaces navigation failure after a session has already been created", async () => {
const { context, flow } = createDraftFixture();
vi.mocked(context.sessions.createResult).mockResolvedValue({
@@ -729,14 +821,24 @@ describe("DraftSubmissionFlow", () => {
});
it.each([
{ scenario: "keeps startup progress active through navigation", navigationError: null },
{
scenario: "keeps startup progress active through navigation",
navigationError: null,
canonicalSessionKey: null,
},
{
scenario: "keeps placement ownership when the Gateway promotes a new session key",
navigationError: null,
canonicalSessionKey: "agent:cloud:dashboard:server-key",
},
{
scenario: "surfaces navigation failure after placement startup commits",
navigationError: "Placement chat route failed to load",
canonicalSessionKey: null,
},
])("$scenario", async ({ navigationError }) => {
])("$scenario", async ({ canonicalSessionKey, navigationError }) => {
const createResult = vi.fn(async (params: Record<string, unknown>) => ({
key: String(params.key),
key: canonicalSessionKey ?? String(params.key),
initialRun: { status: "idle" as const },
}));
const start = vi.fn(
@@ -6,6 +6,7 @@ import {
type SessionMethodAccess,
} from "../../lib/session-method-access.ts";
import { openTerminalSessionInTerminal } from "../../lib/sessions/catalog-terminal.ts";
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import type { SessionPlacementRecovery } from "../../lib/sessions/session-placement-recovery.ts";
import {
@@ -32,6 +33,7 @@ import {
projectDraftSessionPlacementRecovery,
resolveDraftSessionPlacement,
} from "./draft-session-placement.ts";
import { DraftSessionStartup } from "./draft-session-startup.ts";
import type {
DraftSubmissionCallbacks,
DraftSubmissionSnapshot,
@@ -58,6 +60,7 @@ export class DraftSubmissionFlow {
private readonly startedSession = new StartedSessionNavigation();
error: string | null = null;
private submitRequestToken = 0;
private readonly sessionStartup: DraftSessionStartup;
readonly pendingPlacement = new PendingSessionPlacementRecoveryState();
readonly attachmentDraft: NewSessionAttachmentDraft;
readonly composerTextarea = new NewSessionComposerTextareaController();
@@ -69,6 +72,7 @@ export class DraftSubmissionFlow {
private readonly read: () => DraftSubmissionSnapshot,
private readonly callbacks: DraftSubmissionCallbacks,
) {
this.sessionStartup = new DraftSessionStartup(gateway);
this.draftPersistence = new NewSessionDraftPersistence(
() => ({
message: this.messageValue,
@@ -102,13 +106,23 @@ export class DraftSubmissionFlow {
}
get submitting(): boolean {
return this.submittingValue;
return this.submittingValue || this.sessionStartup.active;
}
get submissionOutcomeUnknown(): SubmissionOutcomeReason | null {
return this.submissionOutcomeUnknownValue;
}
resumeInterruptedSubmission() {
const startup = this.sessionStartup.resume();
if (startup.kind === "resume") {
void this.submit(startup);
} else if (startup.kind !== "wait") {
this.submissionOutcomeUnknownValue = "gateway-changed";
this.callbacks.requestUpdate();
}
}
setMessage(message: string) {
this.startedSession.current = null;
this.messageValue = message;
@@ -209,10 +223,10 @@ export class DraftSubmissionFlow {
private buildDraftSessionCreateParams(
options: {
message?: string;
attachments?: unknown[];
attachments?: SessionCreateParams["attachments"];
visibility?: NewSessionVisibility;
} = {},
): Record<string, unknown> {
): SessionCreateParams {
const snapshot = this.read();
return assembleDraftSessionCreateParams({
agentId: this.place.agentId,
@@ -246,22 +260,16 @@ export class DraftSubmissionFlow {
requiredScope: "operator.write",
});
}
if (!pendingPlacement || this.pendingPlacement.phase === "creating") {
const target = this.placement().target;
if (!target || !pendingPlacement || this.pendingPlacement.phase === "creating") {
const createAccess = readSessionMethodAccess(gateway, {
method: "sessions.create",
params: createParams,
});
if (!createAccess.allowed || !this.placement().target) {
if (!createAccess.allowed || !target) {
return createAccess;
}
}
const target = this.placement().target;
if (!target) {
return readSessionMethodAccess(gateway, {
method: "sessions.create",
params: createParams,
});
}
return readSessionMethodAccess(gateway, {
method: "sessions.dispatch",
requiredScope: target.kind === "profile" ? "operator.admin" : "operator.write",
@@ -322,7 +330,10 @@ export class DraftSubmissionFlow {
),
placementTargetForSubmission: () => this.placement().target,
cloudDisabledReason: () => this.cloudDisabledReason(),
cloudRuntimeUnsupportedReason: () => this.cloudRuntimeUnsupportedReason(),
cloudRuntimeUnsupportedReason: () =>
this.place.modelControl.cloudRuntimeUnsupportedReason(
this.gateway.cloudProfiles.find((profile) => profile.id === this.place.cloudProfileId),
),
},
kind,
);
@@ -359,7 +370,10 @@ export class DraftSubmissionFlow {
invalidate(outcomeUnknown: SubmissionOutcomeReason | null = null) {
this.submitRequestToken += 1;
this.startedSession.current = null;
if (outcomeUnknown && this.submittingValue) {
if (
(outcomeUnknown && this.submittingValue && !this.sessionStartup.interrupt()) ||
this.sessionStartup.retireChangedOwner()
) {
this.submissionOutcomeUnknownValue = outcomeUnknown;
}
this.submittingValue = false;
@@ -367,6 +381,7 @@ export class DraftSubmissionFlow {
}
resetDraft() {
this.sessionStartup.clear();
const preservePendingPlacement = Boolean(this.pendingPlacement.sessionKey);
this.blockedSubmitGate = null;
this.invalidate();
@@ -413,22 +428,27 @@ export class DraftSubmissionFlow {
this.applyRecoveryDraft(recovery);
}
async submit() {
async submit(startup?: { params: SessionCreateParams; startedAt: number }) {
const context = this.read().context;
if (!context || !this.canSubmit()) {
if (!context || (!startup && !this.canSubmit())) {
this.noteBlockedSubmitAttempt();
return;
}
this.blockedSubmitGate = null;
const pendingPlacement = Boolean(this.pendingPlacement.sessionKey);
const message = pendingPlacement ? this.pendingPlacement.message : this.messageValue.trim();
const pendingPlacement = !startup && Boolean(this.pendingPlacement.sessionKey);
const message =
startup?.params.message ??
(pendingPlacement ? this.pendingPlacement.message : this.messageValue.trim());
const attachments = this.attachmentDraft.attachments;
const apiAttachments = pendingPlacement
? this.pendingPlacement.attachments
: buildChatApiAttachments(attachments);
const submissionAgentId = pendingPlacement
? this.pendingPlacement.agentId
: normalizeAgentId(this.place.agentId);
const draftAttachments = startup
? startup.params.attachments
: pendingPlacement
? undefined
: buildChatApiAttachments(attachments);
const apiAttachments = pendingPlacement ? this.pendingPlacement.attachments : draftAttachments;
const submissionAgentId =
startup?.params.agentId ??
(pendingPlacement ? this.pendingPlacement.agentId : normalizeAgentId(this.place.agentId));
const submissionGatewayUrl = pendingPlacement
? this.pendingPlacement.gatewayUrl
: context.gateway.connection.gatewayUrl;
@@ -440,7 +460,7 @@ export class DraftSubmissionFlow {
? this.pendingPlacement.recoveryScope
: submissionClient.recoveryScope;
const requestId = ++this.submitRequestToken;
const submittedAt = Date.now();
const submittedAt = startup?.startedAt ?? Date.now();
this.submittingValue = true;
this.error = null;
this.place.browser.close();
@@ -453,7 +473,7 @@ export class DraftSubmissionFlow {
return;
}
this.startedSession.current = null;
const remoteProject = pendingPlacement ? null : this.place.browser.remoteProject;
const remoteProject = pendingPlacement || startup ? null : this.place.browser.remoteProject;
if (remoteProject && !remoteProject.projectId && !this.place.browser.projectId) {
const project = await submissionClient.request<ProjectsAddResult>(
"projects.add",
@@ -465,13 +485,17 @@ export class DraftSubmissionFlow {
}
this.place.browser.recordRemoteProjectId(remoteProject.cloneUrl, project.id);
}
const { target: placementTarget } = this.placement();
const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft();
const createParams = this.buildDraftSessionCreateParams({
message: placementTarget ? "" : message,
visibility: draftRetired ? "normal" : this.visibilityValue,
attachments: placementTarget ? undefined : apiAttachments,
});
const placementTarget = startup ? null : this.placement().target;
const createParams =
startup?.params ??
this.buildDraftSessionCreateParams({
message: placementTarget ? "" : message,
visibility:
this.visibilityValue === "draft" && !this.canStartAsDraft()
? "normal"
: this.visibilityValue,
attachments: placementTarget ? undefined : draftAttachments,
});
const placementCreateParams = placementTarget
? pendingPlacement
? this.pendingPlacement.createParams
@@ -486,27 +510,26 @@ export class DraftSubmissionFlow {
persistent: this.visibilityValue !== "incognito",
})
: undefined;
const requestAccess = this.submissionAccess(placementCreateParams ?? createParams);
const requestAccess = startup
? readSessionMethodAccess(context.gateway.snapshot, {
method: "sessions.create",
params: createParams,
})
: this.submissionAccess(placementCreateParams ?? createParams);
if (!requestAccess.allowed) {
this.sessionStartup.clear();
this.error = requestAccess.reason;
return;
}
if (placementTarget && !pendingPlacement && !placementCreateParams) {
this.error = t("newSession.placementStartFailed", {
error: "placement recovery storage is unavailable",
});
return;
}
const submissionPlacementRecovery = placementTarget ? this.pendingPlacement.capture() : null;
if (placementTarget && !submissionPlacementRecovery) {
this.error = t("newSession.placementStartFailed", {
error: "placement recovery storage is unavailable",
});
this.setPlacementRecoveryUnavailable();
return;
}
const recoveryOwnerKey = submissionPlacementRecovery?.sessionKey ?? "";
const ownsSubmissionRecovery = () =>
this.pendingPlacement.owns(submissionGatewayUrl, submissionRecoveryScope, recoveryOwnerKey);
const ownsRecovery = (sessionKey: string) =>
this.pendingPlacement.owns(submissionGatewayUrl, submissionRecoveryScope, sessionKey);
const ownsSubmissionRecovery = () => ownsRecovery(recoveryOwnerKey);
const isSubmissionLifecycleCurrent = () =>
this.read().isConnected &&
submissionClient.recoveryScopeReady &&
@@ -517,9 +540,10 @@ export class DraftSubmissionFlow {
const result =
pendingPlacement && this.pendingPlacement.phase !== "creating"
? { key: this.pendingPlacement.sessionKey, initialRun: { status: "idle" as const } }
: await context.sessions.createResult(placementCreateParams ?? createParams, {
reconciliation: "background",
});
: await context.sessions.createResult(
placementCreateParams ?? startup?.params ?? this.sessionStartup.start(createParams),
{ reconciliation: "background" },
);
if (requestId !== this.submitRequestToken && !placementTarget) {
return;
}
@@ -527,6 +551,7 @@ export class DraftSubmissionFlow {
if (requestId !== this.submitRequestToken) {
return;
}
this.sessionStartup.clear();
this.error = context.sessions.state.error ?? t("newSession.createFailed");
return;
}
@@ -553,20 +578,15 @@ export class DraftSubmissionFlow {
if (
submissionPlacementRecovery.phase === "creating" &&
isSubmissionLifecycleCurrent() &&
ownsSubmissionRecovery()
ownsSubmissionRecovery() &&
!this.pendingPlacement.promoteToDispatching(result.key)
) {
if (!this.pendingPlacement.promoteToDispatching(result.key)) {
this.error = t("newSession.placementStartFailed", {
error: "placement recovery storage is unavailable",
});
return;
}
this.setPlacementRecoveryUnavailable();
return;
}
const recovery = this.pendingPlacement.capture();
if (!recovery || recovery.phase === "creating") {
this.error = t("newSession.placementStartFailed", {
error: "placement recovery storage is unavailable",
});
this.setPlacementRecoveryUnavailable();
return;
}
if (requestId !== this.submitRequestToken) {
@@ -578,27 +598,13 @@ export class DraftSubmissionFlow {
recovering: pendingPlacement,
createdAt: submittedAt,
});
if (
requestId !== this.submitRequestToken ||
!isSubmissionLifecycleCurrent() ||
!this.pendingPlacement.owns(
submissionGatewayUrl,
submissionRecoveryScope,
recovery.sessionKey,
)
) {
const ownsStartedPlacement = () =>
isSubmissionLifecycleCurrent() && ownsRecovery(recovery.sessionKey);
if (!ownsStartedPlacement()) {
return;
}
await this.draftPersistence.clearSubmittedDraft();
if (
requestId !== this.submitRequestToken ||
!isSubmissionLifecycleCurrent() ||
!this.pendingPlacement.owns(
submissionGatewayUrl,
submissionRecoveryScope,
recovery.sessionKey,
)
) {
if (!ownsStartedPlacement()) {
return;
}
this.pendingPlacement.reset();
@@ -645,8 +651,10 @@ export class DraftSubmissionFlow {
key: result.key,
agentId: submissionAgentId,
});
this.sessionStartup.clear();
} catch (error) {
if (requestId === this.submitRequestToken && this.gateway.client === submissionClient) {
this.sessionStartup.clear();
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
@@ -720,11 +728,10 @@ export class DraftSubmissionFlow {
private placement = () => resolveDraftSessionPlacement(this.pendingPlacement, this.place);
private cloudRuntimeUnsupportedReason(): string | undefined {
const profile = this.gateway.cloudProfiles.find(
(candidate) => candidate.id === this.place.cloudProfileId,
);
return this.place.modelControl.cloudRuntimeUnsupportedReason(profile);
private setPlacementRecoveryUnavailable() {
this.error = t("newSession.placementStartFailed", {
error: "placement recovery storage is unavailable",
});
}
private applyRecoveryDraft(recovery: SessionPlacementRecovery) {
+3 -2
View File
@@ -290,6 +290,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
}
this.place.restorePreferenceSelections();
activateDraft(this.submission, openKey);
this.submission.resumeInterruptedSubmission();
}
private invalidateGatewayDiscovery(
@@ -594,7 +595,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
agent: this.place.selectedAgent(),
agentId: this.place.agentId,
attachmentDraft: this.submission.attachmentDraft,
canSubmit: this.submission.canSubmit(),
canSubmit: !this.submission.submitting && this.submission.canSubmit(),
submitDisabledReason: this.submission.submitDisabledReason(),
blockedSubmitNotice: this.submission.blockedSubmitNotice(),
context: this.context,
@@ -610,7 +611,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
messageLocked: Boolean(this.submission.pendingPlacement.sessionKey),
terminalAction: this.submission.showStartInTerminal()
? {
canStart: this.submission.canSubmit("terminal"),
canStart: !this.submission.submitting && this.submission.canSubmit("terminal"),
disabledReason: this.submission.terminalStartDisabledReason(),
onStart: () => void this.submission.startInTerminal(),
}