diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 2165668ae695..ab71835b4107 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -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; export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets); diff --git a/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts index 63701fad5b1f..8a273d5dc8b0 100644 --- a/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts +++ b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index 7d5311413fc8..244016a89674 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -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)), diff --git a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts index 129df7665fdb..234bd0c448d9 100644 --- a/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts +++ b/ui/src/e2e/new-session-page.connect-machine.e2e.test.ts @@ -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(); diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index 0a97b124b49a..4098abce2b32 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -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 }) => { diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index dcc8dadb31c8..54e21e53eb31 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -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(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fb2c1b6b9a76..77989a0e9c32 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/sessions/session-placement-recovery.ts b/ui/src/lib/sessions/session-placement-recovery.ts index 2c84832bbcf7..b0ee3de87046 100644 --- a/ui/src/lib/sessions/session-placement-recovery.ts +++ b/ui/src/lib/sessions/session-placement-recovery.ts @@ -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([ "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) || diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 128715c09bd2..27732ee48f6a 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -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); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index dbc0a03fc2d9..45145e9cc989 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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("[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", () => { diff --git a/ui/src/pages/chat/components/chat-composer-plus-menu.ts b/ui/src/pages/chat/components/chat-composer-plus-menu.ts index e792c6253813..8b533aaeb102 100644 --- a/ui/src/pages/chat/components/chat-composer-plus-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-plus-menu.ts @@ -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` 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; + }; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 600fe9f84482..934c99247e66 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -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}
${renderAttachmentPreview(props)} @@ -461,26 +463,11 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
${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 diff --git a/ui/src/pages/chat/components/chat-effort-picker.ts b/ui/src/pages/chat/components/chat-effort-picker.ts index ed3f585a8c07..26c2f8db2396 100644 --- a/ui/src/pages/chat/components/chat-effort-picker.ts +++ b/ui/src/pages/chat/components/chat-effort-picker.ts @@ -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`
syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement)} + @toggle=${(event: Event) => { + handleChatComposerDetailsToggle(event); + syncChatPickerOverlay(event.currentTarget as HTMLDetailsElement); + }} >
` : null} - ${status} + ${status}
`; } diff --git a/ui/src/pages/new-session/connect-machine-dialog.ts b/ui/src/pages/new-session/connect-machine-dialog.ts index bbf067a3f118..d00c867c9c75 100644 --- a/ui/src/pages/new-session/connect-machine-dialog.ts +++ b/ui/src/pages/new-session/connect-machine-dialog.ts @@ -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) { diff --git a/ui/src/pages/new-session/draft-session-placement.test.ts b/ui/src/pages/new-session/draft-session-placement.test.ts index b844bd45b38b..27334a450684 100644 --- a/ui/src/pages/new-session/draft-session-placement.test.ts +++ b/ui/src/pages/new-session/draft-session-placement.test.ts @@ -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 } }, }, diff --git a/ui/src/pages/new-session/draft-session-placement.ts b/ui/src/pages/new-session/draft-session-placement.ts index fc1a25ca401a..aadc359ef28f 100644 --- a/ui/src/pages/new-session/draft-session-placement.ts +++ b/ui/src/pages/new-session/draft-session-placement.ts @@ -55,6 +55,7 @@ export function projectDraftSessionPlacementRecovery(recovery: SessionPlacementR attachments: restoreChatApiAttachments(recovery.attachments), visibility, toolOverrides: recovery.createParams?.toolOverrides ?? null, + permissionMode: recovery.createParams?.permissionMode, }, }; } diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index c49251632e3a..4acef40fbf91 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -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; diff --git a/ui/src/pages/new-session/permission-selection.test.ts b/ui/src/pages/new-session/permission-selection.test.ts new file mode 100644 index 000000000000..6b592cf4e42d --- /dev/null +++ b/ui/src/pages/new-session/permission-selection.test.ts @@ -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(); + }); +}); diff --git a/ui/src/pages/new-session/permission-selection.ts b/ui/src/pages/new-session/permission-selection.ts index d00d96e816da..4fef716f6dab 100644 --- a/ui/src/pages/new-session/permission-selection.ts +++ b/ui/src/pages/new-session/permission-selection.ts @@ -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; } diff --git a/ui/src/pages/new-session/session-placement-recovery-state.test.ts b/ui/src/pages/new-session/session-placement-recovery-state.test.ts index 37ec98711336..e054d4b7572b 100644 --- a/ui/src/pages/new-session/session-placement-recovery-state.test.ts +++ b/ui/src/pages/new-session/session-placement-recovery-state.test.ts @@ -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) => { diff --git a/ui/src/pages/new-session/submit-gates.ts b/ui/src/pages/new-session/submit-gates.ts index a15466a1a2af..cd8f5ad06212 100644 --- a/ui/src/pages/new-session/submit-gates.ts +++ b/ui/src/pages/new-session/submit-gates.ts @@ -50,6 +50,20 @@ export const PAGE_RENDERED_GATES: ReadonlySet = 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;