fix(ui): address composer review findings

This commit is contained in:
vyctorbrzezowski
2026-08-25 14:58:14 +00:00
parent d834bf7dd8
commit 35ff8d8728
24 changed files with 191 additions and 116 deletions
+3 -5
View File
@@ -33,11 +33,9 @@ const controlUiPerformanceBudgets = {
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
startupCssGzipBytes: 45 * KIB,
largestJsGzipBytes: 215 * KIB,
// Startup CSS stays at 45 KiB; the boot-group consolidation (2026-08,
// control-ui-boot chunking) merges boot-path component CSS into one file
// that lands just above it, trading ~1 KiB of ceiling for ~95 fewer boot
// requests on HTTP/1.1 gateways.
largestCssGzipBytes: 47 * KIB,
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
largestCssGzipBytes: 53 * KIB,
} satisfies Record<string, number>;
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
@@ -178,10 +178,6 @@ async function openMenu(page: Page) {
return composer;
}
function webSearchSwitch(menu: import("playwright").Locator) {
return menu.locator('.agent-chat__capability-menu-switch[aria-label="Web search"]');
}
function webSearchItem(menu: import("playwright").Locator) {
return menu.locator('wa-dropdown-item[value="toggle-web-search"]');
}
@@ -236,7 +232,7 @@ suite.define(() => {
expect.stringContaining("Manage plugins"),
]),
);
await expect.poll(() => webSearchSwitch(dropdown).isVisible()).toBe(true);
await expect.poll(() => webSearchItem(dropdown).isVisible()).toBe(true);
const skillsRoot = dropdown.getByRole("menuitem", { name: /^Skills/ });
await skillsRoot.focus();
await skillsRoot.evaluate((item) => {
@@ -304,7 +300,7 @@ suite.define(() => {
.toBe(false);
await menu.getByRole("menuitem", { name: "Back" }).click();
const webSearch = webSearchSwitch(menu);
const webSearch = webSearchItem(menu);
await expect
.poll(() =>
webSearch.evaluate((node) => (node as HTMLElement & { checked: boolean }).checked),
@@ -629,9 +625,9 @@ suite.define(() => {
]);
const composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
const webSearch = webSearchSwitch(menu);
await expect.poll(() => webSearchItem(menu).isDisabled()).toBe(true);
await expect.poll(() => webSearchItem(menu).getAttribute("title")).toBe("Loading…");
const webSearch = webSearchItem(menu);
await expect.poll(() => webSearch.isDisabled()).toBe(true);
await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…");
await webSearch.evaluate((item) => {
item
.closest("wa-dropdown")
@@ -678,7 +674,7 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}chat`);
const composer = await openMenu(page);
const menu = composer.locator("wa-dropdown.agent-chat__capability-menu");
await expect.poll(() => webSearchSwitch(menu).count()).toBe(1);
await expect.poll(() => webSearchItem(menu).count()).toBe(1);
await menu.getByRole("menuitem", { name: /^Skills/ }).click();
await expect.poll(() => menu.getByText("No skills available.").isVisible()).toBe(true);
await menu.getByRole("menuitem", { name: "Back" }).click();
@@ -606,7 +606,7 @@ suite.define(() => {
return [style.paddingInlineStart, style.paddingInlineEnd];
}),
)
.toEqual(["10px", "10px"]);
.toEqual(["0px", "0px"]);
await expect
.poll(() =>
effort.evaluate((node) => {
@@ -614,7 +614,7 @@ suite.define(() => {
return [style.paddingInlineStart, style.paddingInlineEnd];
}),
)
.toEqual(["9px", "11px"]);
.toEqual(["4px", "4px"]);
for (const control of [mobileModelBox, mobileContextBox]) {
expect(
Math.abs(control.y + control.height / 2 - (mobileModelBox.y + mobileModelBox.height / 2)),
@@ -178,6 +178,34 @@ suite.define(() => {
}
});
it("redacts sensitive connection-link failures before rendering them", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["device.pair.setupCode"],
});
const secret = "e2e-pairing-bearer-secret";
try {
await page.goto(`${suite.server.baseUrl}new`);
await page.locator("#new-session-where-trigger").click();
await page.getByRole("button", { name: "Connect a machine…" }).click();
await gateway.waitForRequest("device.pair.setupCode");
await gateway.rejectDeferred("device.pair.setupCode", {
message: `pairing failed: Authorization: Bearer ${secret}`,
});
const alert = page
.locator('openclaw-modal-dialog[label="Connect a machine"]')
.getByRole("alert");
await alert.waitFor();
expect(await alert.textContent()).toContain("Authorization: [redacted]");
expect(await alert.textContent()).not.toContain(secret);
} finally {
await context.close();
}
});
it("closes an in-flight connection dialog when the Gateway reconnects", async () => {
const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
@@ -316,13 +316,6 @@ export async function openNewSessionPlusMenu(page: Page) {
return menu;
}
export async function selectNewSessionDraft(page: Page) {
const menu = await openNewSessionPlusMenu(page);
await menu.getByRole("menuitem", { name: "Draft" }).click();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Draft", exact: true }).waitFor();
}
export async function navigateInApp(page: Page, routeId: string, search = "") {
await page.evaluate(
({ targetRouteId, targetSearch }) => {
+3 -8
View File
@@ -6,11 +6,7 @@ import { expect as expectBrowser } from "playwright/test";
import { afterEach, expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
import {
openNewSessionPlusMenu,
replaceGatewayClient,
selectNewSessionDraft,
} from "./new-session-page.test-support.ts";
import { openNewSessionPlusMenu, replaceGatewayClient } from "./new-session-page.test-support.ts";
import {
avatarLabelCenterDelta,
routeAvatarFixtures,
@@ -607,9 +603,8 @@ suite.define(() => {
await currentPage.locator(".new-session-page__composer .agent-chat__composer-footer").hover();
await draftToggle.waitFor();
await captureUiProof(currentPage, "02-create-draft-available.png");
await menu.getByRole("menuitem", { name: "Draft" }).click();
await currentPage.keyboard.press("Escape");
await currentPage.getByRole("button", { name: "Draft", exact: true }).waitFor();
await draftToggle.check();
await expectBrowser(draftToggle).toBeChecked();
await currentPage.locator(".new-session-page__message").fill("work privately first");
await captureUiProof(currentPage, "03-create-draft-selected.png");
await currentPage.getByRole("button", { name: "Start session" }).click();
+2
View File
@@ -5767,6 +5767,8 @@ export const en: TranslationMap = {
searchModels: "Search models",
noMatchingModels: "No models match your search",
sessionOverride: "Session override",
resetToDefault: "Reset to default ({model})",
useDefault: "Use default",
defaultWithModel: "Default ({model})",
defaultWithLevel: "Default ({level})",
fastHelp: "Fast responses finish sooner and can use more of your usage limits.",
@@ -1,4 +1,7 @@
import { SessionToolOverridesSchema } from "@openclaw/gateway-protocol";
import {
SessionPermissionModeSchema,
SessionToolOverridesSchema,
} from "@openclaw/gateway-protocol";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import { Value } from "typebox/value";
@@ -54,6 +57,7 @@ const PLACEMENT_CREATE_FIELDS = new Set<string>([
"worktree",
"incognito",
"visibility",
"permissionMode",
"toolOverrides",
...PLACEMENT_CREATE_STRING_FIELDS,
]);
@@ -75,6 +79,8 @@ export function parseSessionPlacementCreateParams(
record.worktree !== true ||
(record.incognito !== undefined && record.incognito !== true) ||
(record.visibility !== undefined && record.visibility !== "draft") ||
(record.permissionMode !== undefined &&
!Value.Check(SessionPermissionModeSchema, record.permissionMode)) ||
(record.toolOverrides !== undefined &&
!Value.Check(SessionToolOverridesSchema, record.toolOverrides)) ||
(record.projectId !== undefined && record.cwd !== undefined) ||
@@ -3209,8 +3209,12 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
.locator(".agent-chat__composer-combobox > textarea")
.evaluate((textareaNode) => Number.parseFloat(getComputedStyle(textareaNode).fontSize));
if (width <= 768) {
expect(controls.modelTriggerPadding).toEqual({ end: 10, start: 10 });
expect(controls.effortTriggerPadding).toEqual({ end: 10, start: 10 });
const modelPadding = width <= 480 ? 0 : 4;
expect(controls.modelTriggerPadding).toEqual({
end: modelPadding,
start: modelPadding,
});
expect(controls.effortTriggerPadding).toEqual({ end: 4, start: 4 });
expect(composerFontSize).toBe(16);
expect(model.width).toBeGreaterThanOrEqual(40);
expect(model.width).toBeLessThanOrEqual(footer.width);
+12 -4
View File
@@ -5903,22 +5903,30 @@ describe("chat model controls", () => {
expect(onThinkingSelect).not.toHaveBeenCalled();
});
it("shows override provenance without a separate reset action", () => {
it("shows override provenance with a reset action", () => {
const { state } = createChatHeaderState({
model: null,
models: createOpenAiModelCatalog(),
});
const onModelSelect = vi.fn(async () => true);
const container = renderModelControls(state);
expect(container.querySelector(".chat-controls__model-provenance")).toBeNull();
expect(container.querySelector("[data-chat-model-reset]")).toBeNull();
renderModelControls(state, { modelOverrides: { main: "openai/gpt-5.4" } }, container);
renderModelControls(
state,
{ modelOverrides: { main: "openai/gpt-5.4" }, onModelSelect },
container,
);
expect(container.querySelector(".chat-controls__model-provenance")?.textContent?.trim()).toBe(
expect(container.querySelector(".chat-controls__model-provenance")?.textContent).toContain(
"Session override",
);
expect(container.querySelector("[data-chat-model-reset]")).toBeNull();
const reset = container.querySelector<HTMLButtonElement>("[data-chat-model-reset]");
expect(reset).toBeInstanceOf(HTMLButtonElement);
reset?.click();
expect(onModelSelect).toHaveBeenCalledWith("", "main");
});
it("hides model choices for locked sessions while preserving reasoning and speed", () => {
@@ -38,7 +38,7 @@ export type ChatComposerMenuSkill = {
type ChatComposerRootToggle = {
value: string;
label: string;
icon?: unknown;
icon?: TemplateResult;
checked: boolean;
disabled: boolean;
title?: string;
@@ -124,9 +124,8 @@ function renderCapabilityToggleRow(options: {
checked: boolean;
disabled: boolean;
title: string | null | undefined;
icon?: unknown;
note?: TemplateResult | typeof nothing;
icon?: TemplateResult;
note?: TemplateResult | typeof nothing;
}) {
return html`
<wa-dropdown-item
@@ -148,33 +148,27 @@ type ComposingDraft = {
value: string;
};
export type ChatComposerState = SkillMenuState & {
slashMenuOpen: boolean;
slashMenuItems: SlashCommandDef[];
slashMenuIndex: number;
slashMenuMode: "command" | "args";
slashMenuCommand: SlashCommandDef | null;
slashMenuArgItems: string[];
slashCommandRefreshPending: boolean;
composerComposing: boolean;
composingDraft: ComposingDraft | null;
composerInputIntentKey: string | null;
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
goalExpandedId: string | null;
activeGatewayQuestionId: string | null;
gatewayQuestionCollapsed: boolean;
questionTakeoverActive: boolean;
restoreComposerFocus: boolean;
composerInput: HTMLElement | null;
composerTextarea: HTMLTextAreaElement | null;
microphonePicker: ComposerMicrophonePicker | null;
capabilityMenuOpen: boolean;
capabilityMenuView: ChatComposerPlusMenuView;
// Stable Lit refs: inline arrows would change identity per render and force
// layout observers to detach and reconnect on every chat update.
composerInputRef: ((element?: Element) => void) | null;
textareaRef: ((element?: Element) => void) | null;
dictation: ComposerDictationController | null;
dictationDraftKey: string | null;
dictationSelection: { start: number; end: number } | null;
};
export type ChatComposerState = SkillMenuState &
SlashMenuState & {
composerComposing: boolean;
composingDraft: ComposingDraft | null;
composerInputIntentKey: string | null;
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
goalExpandedId: string | null;
activeGatewayQuestionId: string | null;
gatewayQuestionCollapsed: boolean;
questionTakeoverActive: boolean;
restoreComposerFocus: boolean;
composerInput: HTMLElement | null;
composerTextarea: HTMLTextAreaElement | null;
microphonePicker: ComposerMicrophonePicker | null;
capabilityMenuOpen: boolean;
capabilityMenuView: ChatComposerPlusMenuView;
// Stable Lit refs: inline arrows would change identity per render and force
// layout observers to detach and reconnect on every chat update.
composerInputRef: ((element?: Element) => void) | null;
textareaRef: ((element?: Element) => void) | null;
dictation: ComposerDictationController | null;
dictationDraftKey: string | null;
dictationSelection: { start: number; end: number } | null;
};
@@ -297,7 +297,9 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
}}
${ref(state.composerInputRef ?? undefined)}
>
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
${slashMenuVisible
? renderSlashMenu(state, slashMenuHost, visibleDraft, requestUpdate)
: nothing}
${skillMenuVisible ? renderSkillMenu(state, skillMenuHost, requestUpdate) : nothing}
<div class="agent-chat__composer-lede">
${renderAttachmentPreview(props)}
@@ -461,26 +463,11 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
<div class="agent-chat__composer-lead agent-chat__composer-meta">
${renderChatComposerPlusMenu({
attachments: props,
showCapabilities: props.capabilityMenu !== undefined,
basePath: props.capabilityMenu?.basePath ?? "",
capabilityMenu: props.capabilityMenu,
disabled: !canCompose || props.suggestionComposer === true,
open: state.capabilityMenuOpen,
view: state.capabilityMenuView,
toolOverrides: props.toolOverrides,
skills: props.capabilityMenu?.skills ?? null,
skillsLoading: props.capabilityMenu?.skillsLoading ?? false,
skillsError: props.capabilityMenu?.skillsError ?? false,
mcpServers: props.capabilityMenu?.mcpServers ?? [],
toolsEffectiveResult: props.capabilityMenu?.toolsEffectiveResult ?? null,
toolsEffectiveLoading: props.capabilityMenu?.toolsEffectiveLoading ?? false,
toolsEffectiveError: props.capabilityMenu?.toolsEffectiveError ?? false,
toolAccessMutationBlockedReason:
props.capabilityMenu?.toolAccessMutationBlockedReason ?? null,
webSearchBaseEnabled: props.capabilityMenu?.webSearchBaseEnabled ?? true,
mutationBlockedReason: props.capabilityMenu?.mutationBlockedReason ?? null,
canAdmin: props.capabilityMenu?.canAdmin ?? false,
adminBlockedReason: props.capabilityMenu?.adminBlockedReason ?? null,
addServerDialog: props.capabilityMenu?.addServerDialog,
onOpenChange: (open) => {
state.capabilityMenuOpen = open;
if (!open) {
@@ -492,12 +479,6 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
state.capabilityMenuView = view;
requestUpdate();
},
onLoadSkills: props.capabilityMenu?.onLoadSkills ?? (() => {}),
onPatchToolOverrides: props.capabilityMenu?.onPatchToolOverrides ?? (() => {}),
onNavigate: props.capabilityMenu?.onNavigate ?? (() => {}),
onAddServer: props.capabilityMenu?.onAddServer,
onEnsureToolAccess: props.capabilityMenu?.onEnsureToolAccess,
onOpenToolAccess: props.capabilityMenu?.onOpenToolAccess,
})}
${composerLeadControl}
${props.queuedEdit?.editingId
@@ -7,7 +7,7 @@ import type {
ChatFastModeSelectValue,
} from "../../../lib/chat/model-select-state.ts";
import type { ChatThinkingSelectState } from "../../../lib/chat/thinking.ts";
import { syncChatPickerOverlay } from "./chat-picker-overlay.ts";
import { handleChatComposerDetailsToggle, syncChatPickerOverlay } from "./chat-picker-overlay.ts";
type ChatEffortPickerParams = {
disabled: boolean;
@@ -121,7 +121,10 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
return html`
<details
class="chat-controls__inline-select chat-controls__effort-picker"
@toggle=${(event: Event) => syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement)}
@toggle=${(event: Event) => {
handleChatComposerDetailsToggle(event);
syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement);
}}
>
<summary
class="chat-controls__inline-select-trigger chat-controls__effort-trigger ${params.fastMode
@@ -634,7 +634,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
<span>${t("chat.modelControls.sessionOverride")}</span>
<openclaw-tooltip
.content=${t("chat.modelControls.resetToDefault", {
model: params.defaultModelLabel,
model: defaultModelOption?.label ?? params.triggerModelLabel,
})}
>
<button
@@ -649,9 +649,12 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
return;
}
commitModel("");
const details = (
event.currentTarget as HTMLElement
).closest<HTMLDetailsElement>("details");
const resetButton = event.currentTarget;
if (!(resetButton instanceof HTMLElement)) {
return;
}
const details =
resetButton.closest<HTMLDetailsElement>("details");
if (details) {
details.open = false;
if (event.detail === 0) {
@@ -49,6 +49,6 @@ export function renderChatTypingIndicator(
</span>
</div>`
: null}
<span class="sr-only">${status}</span>
<span class="sr-only" role="status">${status}</span>
</div>`;
}
@@ -6,6 +6,7 @@ import { icons } from "../../components/icons.ts";
import "../../components/modal-dialog.ts";
import { t } from "../../i18n/index.ts";
import { requestDevicePairJoinSetup, type DevicePairSetup } from "../../lib/device-pair-setup.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { formatTimeMs } from "../../lib/format.ts";
/**
@@ -87,7 +88,7 @@ export class ConnectMachineSetupState {
this.setupValue = setup;
} catch (error) {
if (this.stillCurrent(requestId, client)) {
this.errorValue = error instanceof Error ? error.message : String(error);
this.errorValue = formatUiError(error);
}
} finally {
if (requestId === this.requestId) {
@@ -57,6 +57,7 @@ describe("new-session placement target", () => {
key: "agent:main:cloud",
agentId: "main",
message: "",
permissionMode: "guarded",
visibility: "draft",
toolOverrides: { skills: { release: false } },
worktree: true,
@@ -64,6 +65,7 @@ describe("new-session placement target", () => {
}),
).toMatchObject({
draft: {
permissionMode: "guarded",
visibility: "draft",
toolOverrides: { skills: { release: false } },
},
@@ -55,6 +55,7 @@ export function projectDraftSessionPlacementRecovery(recovery: SessionPlacementR
attachments: restoreChatApiAttachments(recovery.attachments),
visibility,
toolOverrides: recovery.createParams?.toolOverrides ?? null,
permissionMode: recovery.createParams?.permissionMode,
},
};
}
@@ -48,6 +48,7 @@ import {
import { StartedSessionNavigation } from "./started-session-navigation.ts";
import {
PAGE_RENDERED_GATES,
resolveCloudPlacementDisabledReason,
resolveNewSessionSubmitBlock,
type NewSessionSubmitBlock,
} from "./submit-gates.ts";
@@ -78,6 +79,7 @@ export class DraftSubmissionFlow {
) {
this.capabilities = new NewSessionCapabilityController(callbacks.requestUpdate);
this.capabilities.setMutationCallback(() => (this.startedSession.current = null));
this.permission.setMutationCallback(() => (this.startedSession.current = null));
this.sessionStartup = new DraftSessionStartup(gateway);
this.draftPersistence = new NewSessionDraftPersistence(
() => ({
@@ -147,11 +149,15 @@ export class DraftSubmissionFlow {
attachments: ChatAttachment[];
visibility: NewSessionVisibility;
toolOverrides?: NewSessionCapabilityController["toolOverrides"];
permissionMode?: SessionCreateParams["permissionMode"];
}) {
this.draftPersistence.noteDraftReplaced();
this.messageValue = state.message;
this.visibilityValue = state.visibility;
this.capabilities.restoreToolOverrides(state.toolOverrides);
if ("permissionMode" in state) {
this.permission.restore(state.permissionMode);
}
this.attachmentDraft.restore(state.attachments);
}
@@ -351,19 +357,7 @@ export class DraftSubmissionFlow {
});
}
cloudDisabledReason(): string | undefined {
const runtimeReason = this.place.modelControl.cloudRuntimeUnsupportedReason();
if (runtimeReason) {
return runtimeReason;
}
if (this.place.repository.kind === "checking") {
return t("newSession.checkingGit");
}
if (this.place.repository.kind === "unavailable" && !this.place.worktreeAvailable()) {
return t("newSession.gitCheckUnavailable");
}
return this.place.worktreeAvailable() ? undefined : t("newSession.cloudRequiresWorktree");
}
cloudDisabledReason = () => resolveCloudPlacementDisabledReason(this.place);
invalidate(outcomeUnknown: SubmissionOutcomeReason | null = null) {
this.submitRequestToken += 1;
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { NewSessionPermissionSelection } from "./permission-selection.ts";
describe("NewSessionPermissionSelection", () => {
it("publishes user mutations without treating recovery as a mutation", () => {
const requestUpdate = vi.fn();
const onMutation = vi.fn();
const selection = new NewSessionPermissionSelection(requestUpdate);
selection.setMutationCallback(onMutation);
selection.restore("guarded");
expect(selection.value).toBe("guarded");
expect(onMutation).not.toHaveBeenCalled();
expect(requestUpdate).not.toHaveBeenCalled();
selection.set("full");
expect(selection.value).toBe("full");
expect(onMutation).toHaveBeenCalledOnce();
expect(requestUpdate).toHaveBeenCalledOnce();
});
});
@@ -2,14 +2,24 @@ import type { SessionPermissionMode } from "../../../../packages/gateway-protoco
export class NewSessionPermissionSelection {
value: SessionPermissionMode | undefined;
private onMutation?: () => void;
constructor(private readonly requestUpdate: () => void) {}
setMutationCallback(callback: () => void) {
this.onMutation = callback;
}
set(value: SessionPermissionMode | undefined) {
this.value = value;
this.onMutation?.();
this.requestUpdate();
}
restore(value: SessionPermissionMode | undefined) {
this.value = value;
}
reset() {
this.value = undefined;
}
@@ -65,6 +65,28 @@ describe("pending session placement recovery state", () => {
});
});
it("preserves the requested permission mode in placement recovery", () => {
const pending = new PendingSessionPlacementRecoveryState();
const createParams = pending.stageCreate({
agentId: "cloud",
target: { kind: "profile", profileId: "aws" },
message: "run remotely with guarded permissions",
gatewayUrl: "ws://gateway.example",
recoveryScope: "principal-a",
createParams: {
agentId: "cloud",
message: "",
permissionMode: "guarded",
worktree: true,
},
});
expect(createParams).toMatchObject({ permissionMode: "guarded" });
expect(
readSessionPlacementRecovery("ws://gateway.example", "principal-a", pending.sessionKey),
).toMatchObject({ createParams: { permissionMode: "guarded" } });
});
it.each(["", "x".repeat(129)])(
"rejects an invalid persisted machine class %#",
(machineClass) => {
+14
View File
@@ -50,6 +50,20 @@ export const PAGE_RENDERED_GATES: ReadonlySet<string> = new Set([
"worktree-name",
]);
export function resolveCloudPlacementDisabledReason(place: DraftPlaceState): string | undefined {
const runtimeReason = place.modelControl.cloudRuntimeUnsupportedReason();
if (runtimeReason) {
return runtimeReason;
}
if (place.repository.kind === "checking") {
return t("newSession.checkingGit");
}
if (place.repository.kind === "unavailable" && !place.worktreeAvailable()) {
return t("newSession.gitCheckUnavailable");
}
return place.worktreeAvailable() ? undefined : t("newSession.cloudRequiresWorktree");
}
/** Facts the gate walk reads from DraftSubmissionFlow, kept read-only. */
type SubmitGateHost = {
readonly gatewayState: DraftGatewayState;