mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(ui): address composer review findings
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user