From f642f9ca87e2069d02f88c5f5ce066ef962a0158 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 24 Feb 2026 12:16:02 -0800 Subject: [PATCH] Improve config toggle UX --- src/app/page.tsx | 25 ++++++--- src/components/theme-toggle.tsx | 4 +- .../agents/components/AgentChatPanel.tsx | 41 +++++++++++++- .../agents/components/AgentInspectPanels.tsx | 30 +++++++--- src/features/agents/components/HeaderBar.tsx | 10 ++-- .../operations/configMutationGatePolicy.ts | 4 +- .../operations/mutationLifecycleWorkflow.ts | 2 + .../useAgentSettingsMutationController.ts | 1 + .../operations/useConfigMutationQueue.ts | 15 ++++- .../useGatewayConfigSyncController.ts | 1 + tests/unit/agentSettingsPanel.test.ts | 56 +++++++++++++++++++ tests/unit/configMutationGatePolicy.test.ts | 22 +++++++- ...ationLifecycleWorkflow.integration.test.ts | 2 + 13 files changed, 182 insertions(+), 31 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index cc420a3..ee8b7f3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -266,7 +266,12 @@ const AgentStudioPage = () => { null ); const enqueueConfigMutationRef = useRef< - (params: { kind: ConfigMutationKind; label: string; run: () => Promise }) => Promise + (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise; + requiresIdleAgents?: boolean; + }) => Promise >((input) => Promise.reject(new Error(`Config mutation queue not ready for "${input.kind}".`))); const approvalPausedRunIdByAgentRef = useRef>(new Map()); @@ -514,12 +519,16 @@ const AgentStudioPage = () => { (createAgentBlock && createAgentBlock.phase !== "queued") ); - const { enqueueConfigMutation, queuedCount: queuedConfigMutationCount, activeConfigMutation } = - useConfigMutationQueue({ - status, - hasRunningAgents, - hasRestartBlockInProgress, - }); + const { + enqueueConfigMutation, + queuedCount: queuedConfigMutationCount, + queuedBlockedByRunningAgents, + activeConfigMutation, + } = useConfigMutationQueue({ + status, + hasRunningAgents, + hasRestartBlockInProgress, + }); enqueueConfigMutationRef.current = enqueueConfigMutation; useEffect(() => { @@ -1167,7 +1176,7 @@ const AgentStudioPage = () => { const configMutationStatusLine = activeConfigMutation ? `Applying config change: ${activeConfigMutation.label}` : queuedConfigMutationCount > 0 - ? hasRunningAgents + ? queuedBlockedByRunningAgents ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish` : status !== "connected" ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection` diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx index a457d9c..ffce30f 100644 --- a/src/components/theme-toggle.tsx +++ b/src/components/theme-toggle.tsx @@ -49,9 +49,9 @@ export const ThemeToggle = () => { type="button" onClick={toggleTheme} aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} - className="ui-btn-icon" + className="ui-btn-icon ui-btn-icon-sm" > - {isDark ? : } + {isDark ? : } ); }; diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index daab237..19b95cd 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -30,7 +30,6 @@ import { type AssistantTraceEvent, type AgentChatItem, } from "./chatItems"; -import { EmptyStatePanel } from "./EmptyStatePanel"; const formatChatTimestamp = (timestampMs: number): string => { return new Intl.DateTimeFormat(undefined, { @@ -496,6 +495,39 @@ const AssistantMessageCard = memo(function AssistantMessageCard({ ); }); +const AssistantIntroCard = memo(function AssistantIntroCard({ + avatarSeed, + avatarUrl, + name, + title, +}: { + avatarSeed: string; + avatarUrl: string | null; + name: string; + title: string; +}) { + return ( +
+
+
+ +
+
+
+ {name} +
+
+
+
{title}
+
+ Try describing a task, bug, or question to get started. +
+
+
+
+ ); +}); + const AgentChatFinalItems = memo(function AgentChatFinalItems({ agentId, name, @@ -729,7 +761,12 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ ) : null} {!hasTranscriptContent ? ( - + ) : ( <> ({ deliveryChannel: "last", }); +const arePermissionsDraftEqual = (a: AgentPermissionsDraft, b: AgentPermissionsDraft): boolean => + a.commandMode === b.commandMode && + a.webAccess === b.webAccess && + a.fileTools === b.fileTools; + const applyTemplateDefaults = (templateId: CronCreateTemplateId, current: CronCreateDraft): CronCreateDraft => { const nextTimeZone = (current.everyTimeZone ?? "").trim() || resolveLocalTimeZone(); const base = { @@ -286,15 +291,19 @@ export const AgentSettingsPanel = ({ onCreateCronJob = () => {}, controlUiUrl = null, }: AgentSettingsPanelProps) => { - const [permissionsDraftValue, setPermissionsDraftValue] = useState( - permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent)) - ); + const initialPermissionsDraft = + permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent)); + const [permissionsBaselineValue, setPermissionsBaselineValue] = + useState(initialPermissionsDraft); + const [permissionsDraftValue, setPermissionsDraftValue] = + useState(initialPermissionsDraft); const [permissionsSaving, setPermissionsSaving] = useState(false); const [permissionsSaveState, setPermissionsSaveState] = useState< "idle" | "saving" | "saved" | "error" >("idle"); const [permissionsSaveError, setPermissionsSaveError] = useState(null); const permissionsSaveTimerRef = useRef(null); + const permissionsDraftAgentIdRef = useRef(agent.agentId); const [expandedCronJobIds, setExpandedCronJobIds] = useState>(() => new Set()); const [cronCreateOpen, setCronCreateOpen] = useState(false); const [cronCreateStep, setCronCreateStep] = useState(0); @@ -307,19 +316,22 @@ export const AgentSettingsPanel = ({ [permissionsDraft, resolvedExecutionRole] ); const permissionsDirty = useMemo( - () => - permissionsDraftValue.commandMode !== resolvedPermissionsDraft.commandMode || - permissionsDraftValue.webAccess !== resolvedPermissionsDraft.webAccess || - permissionsDraftValue.fileTools !== resolvedPermissionsDraft.fileTools, - [permissionsDraftValue, resolvedPermissionsDraft] + () => !arePermissionsDraftEqual(permissionsDraftValue, permissionsBaselineValue), + [permissionsBaselineValue, permissionsDraftValue] ); useEffect(() => { + const agentChanged = permissionsDraftAgentIdRef.current !== agent.agentId; + permissionsDraftAgentIdRef.current = agent.agentId; + setPermissionsBaselineValue(resolvedPermissionsDraft); + if (!agentChanged && (permissionsSaving || permissionsDirty)) { + return; + } setPermissionsDraftValue(resolvedPermissionsDraft); setPermissionsSaveState("idle"); setPermissionsSaveError(null); setPermissionsSaving(false); - }, [agent.agentId, resolvedExecutionRole, resolvedPermissionsDraft]); + }, [agent.agentId, permissionsDirty, permissionsSaving, resolvedPermissionsDraft]); const runPermissionsSave = useCallback(async (draft: AgentPermissionsDraft) => { if (permissionsSaving) return; diff --git a/src/features/agents/components/HeaderBar.tsx b/src/features/agents/components/HeaderBar.tsx index 04698f9..2712b5f 100644 --- a/src/features/agents/components/HeaderBar.tsx +++ b/src/features/agents/components/HeaderBar.tsx @@ -37,10 +37,10 @@ export const HeaderBar = ({ }, [menuOpen]); return ( -
-
+
+
-

+

OpenClaw Studio

@@ -60,13 +60,13 @@ export const HeaderBar = ({
{menuOpen ? ( diff --git a/src/features/agents/operations/configMutationGatePolicy.ts b/src/features/agents/operations/configMutationGatePolicy.ts index 5cabaa3..1f31e3c 100644 --- a/src/features/agents/operations/configMutationGatePolicy.ts +++ b/src/features/agents/operations/configMutationGatePolicy.ts @@ -3,6 +3,7 @@ import type { GatewayStatus } from "./gatewayRestartPolicy"; export type ConfigMutationGateInput = { status: GatewayStatus; hasRunningAgents: boolean; + nextMutationRequiresIdleAgents: boolean; hasActiveMutation: boolean; hasRestartBlockInProgress: boolean; queuedCount: number; @@ -13,7 +14,6 @@ export function shouldStartNextConfigMutation(input: ConfigMutationGateInput): b if (input.queuedCount <= 0) return false; if (input.hasActiveMutation) return false; if (input.hasRestartBlockInProgress) return false; - if (input.hasRunningAgents) return false; + if (input.hasRunningAgents && input.nextMutationRequiresIdleAgents) return false; return true; } - diff --git a/src/features/agents/operations/mutationLifecycleWorkflow.ts b/src/features/agents/operations/mutationLifecycleWorkflow.ts index ad3bd3d..1853244 100644 --- a/src/features/agents/operations/mutationLifecycleWorkflow.ts +++ b/src/features/agents/operations/mutationLifecycleWorkflow.ts @@ -170,6 +170,7 @@ export type AgentConfigMutationLifecycleDeps = { kind: ConfigMutationKind; label: string; run: () => Promise; + requiresIdleAgents?: boolean; }) => Promise; setQueuedBlock: () => void; setMutatingBlock: () => void; @@ -198,6 +199,7 @@ export type CreateAgentMutationLifecycleDeps = { kind: ConfigMutationKind; label: string; run: () => Promise; + requiresIdleAgents?: boolean; }) => Promise; createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>; setQueuedBlock: (params: { agentName: string; startedAt: number }) => void; diff --git a/src/features/agents/operations/useAgentSettingsMutationController.ts b/src/features/agents/operations/useAgentSettingsMutationController.ts index 94c89f5..2f91f5b 100644 --- a/src/features/agents/operations/useAgentSettingsMutationController.ts +++ b/src/features/agents/operations/useAgentSettingsMutationController.ts @@ -46,6 +46,7 @@ export type UseAgentSettingsMutationControllerParams = { kind: ConfigMutationKind; label: string; run: () => Promise; + requiresIdleAgents?: boolean; }) => Promise; gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; settingsRouteActive: boolean; diff --git a/src/features/agents/operations/useConfigMutationQueue.ts b/src/features/agents/operations/useConfigMutationQueue.ts index 82b7010..d3660db 100644 --- a/src/features/agents/operations/useConfigMutationQueue.ts +++ b/src/features/agents/operations/useConfigMutationQueue.ts @@ -16,6 +16,7 @@ type QueuedConfigMutation = { id: string; kind: ConfigMutationKind; label: string; + requiresIdleAgents: boolean; run: () => Promise; resolve: () => void; reject: (error: unknown) => void; @@ -26,6 +27,9 @@ export type ActiveConfigMutation = { label: string; }; +const mutationRequiresIdleAgents = (kind: ConfigMutationKind): boolean => + kind === "create-agent" || kind === "rename-agent" || kind === "delete-agent"; + export function useConfigMutationQueue(params: { status: GatewayStatus; hasRunningAgents: boolean; @@ -37,12 +41,18 @@ export function useConfigMutationQueue(params: { ); const enqueueConfigMutation = useCallback( - (params: { kind: ConfigMutationKind; label: string; run: () => Promise }) => + (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise; + requiresIdleAgents?: boolean; + }) => new Promise((resolve, reject) => { const queued: QueuedConfigMutation = { id: randomUUID(), kind: params.kind, label: params.label, + requiresIdleAgents: params.requiresIdleAgents ?? mutationRequiresIdleAgents(params.kind), run: params.run, resolve, reject, @@ -57,6 +67,7 @@ export function useConfigMutationQueue(params: { !shouldStartNextConfigMutation({ status: params.status, hasRunningAgents: params.hasRunningAgents, + nextMutationRequiresIdleAgents: Boolean(queuedConfigMutations[0]?.requiresIdleAgents), hasActiveMutation: Boolean(activeConfigMutation), hasRestartBlockInProgress: params.hasRestartBlockInProgress, queuedCount: queuedConfigMutations.length, @@ -101,6 +112,8 @@ export function useConfigMutationQueue(params: { return { enqueueConfigMutation, queuedCount: queuedConfigMutations.length, + queuedBlockedByRunningAgents: + Boolean(queuedConfigMutations[0]?.requiresIdleAgents) && params.hasRunningAgents, activeConfigMutation: activeConfigMutation ? ({ kind: activeConfigMutation.kind, label: activeConfigMutation.label } satisfies ActiveConfigMutation) : null, diff --git a/src/features/agents/operations/useGatewayConfigSyncController.ts b/src/features/agents/operations/useGatewayConfigSyncController.ts index 1a54c91..4c6c5d1 100644 --- a/src/features/agents/operations/useGatewayConfigSyncController.ts +++ b/src/features/agents/operations/useGatewayConfigSyncController.ts @@ -31,6 +31,7 @@ export type UseGatewayConfigSyncControllerParams = { kind: "repair-sandbox-tool-allowlist"; label: string; run: () => Promise; + requiresIdleAgents?: boolean; }) => Promise; loadAgents: () => Promise; isDisconnectLikeError: (err: unknown) => boolean; diff --git a/tests/unit/agentSettingsPanel.test.ts b/tests/unit/agentSettingsPanel.test.ts index 165d7c9..10af84e 100644 --- a/tests/unit/agentSettingsPanel.test.ts +++ b/tests/unit/agentSettingsPanel.test.ts @@ -56,6 +56,7 @@ const createCronJob = (id: string): CronJobSummary => ({ describe("AgentSettingsPanel", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); }); it("does_not_render_name_editor_in_capabilities_mode", () => { @@ -221,6 +222,61 @@ describe("AgentSettingsPanel", () => { ); }); + it("preserves_pending_permissions_toggles_during_props_refresh", () => { + const onUpdateAgentPermissions = vi.fn(async () => {}); + + const props = { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + onUpdateAgentPermissions, + }; + + const { rerender } = render( + createElement(AgentSettingsPanel, { + ...props, + permissionsDraft: { + commandMode: "off", + webAccess: false, + fileTools: false, + }, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Run commands auto" })); + fireEvent.click(screen.getByRole("switch", { name: "Web access" })); + fireEvent.click(screen.getByRole("switch", { name: "File tools" })); + + rerender( + createElement(AgentSettingsPanel, { + ...props, + permissionsDraft: { + commandMode: "auto", + webAccess: false, + fileTools: false, + }, + }) + ); + + expect(screen.getByRole("switch", { name: "Web access" })).toHaveAttribute( + "aria-checked", + "true" + ); + expect(screen.getByRole("switch", { name: "File tools" })).toHaveAttribute( + "aria-checked", + "true" + ); + }); + it("does_not_render_runtime_settings_section", () => { render( createElement(AgentSettingsPanel, { diff --git a/tests/unit/configMutationGatePolicy.test.ts b/tests/unit/configMutationGatePolicy.test.ts index 6b4156e..ec38167 100644 --- a/tests/unit/configMutationGatePolicy.test.ts +++ b/tests/unit/configMutationGatePolicy.test.ts @@ -8,6 +8,7 @@ describe("shouldStartNextConfigMutation", () => { shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: false, queuedCount: 0, @@ -20,6 +21,7 @@ describe("shouldStartNextConfigMutation", () => { shouldStartNextConfigMutation({ status: "connecting", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: false, queuedCount: 1, @@ -27,11 +29,12 @@ describe("shouldStartNextConfigMutation", () => { ).toBe(false); }); - it("returns_false_when_running_agents", () => { + it("returns_false_when_running_agents_and_next_mutation_requires_idle_agents", () => { expect( shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: true, + nextMutationRequiresIdleAgents: true, hasActiveMutation: false, hasRestartBlockInProgress: false, queuedCount: 1, @@ -39,11 +42,25 @@ describe("shouldStartNextConfigMutation", () => { ).toBe(false); }); + it("returns_true_when_running_agents_but_next_mutation_does_not_require_idle_agents", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: true, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(true); + }); + it("returns_false_when_active_mutation", () => { expect( shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: true, hasRestartBlockInProgress: false, queuedCount: 1, @@ -56,6 +73,7 @@ describe("shouldStartNextConfigMutation", () => { shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: true, queuedCount: 1, @@ -68,6 +86,7 @@ describe("shouldStartNextConfigMutation", () => { shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: false, queuedCount: 1, @@ -75,4 +94,3 @@ describe("shouldStartNextConfigMutation", () => { ).toBe(true); }); }); - diff --git a/tests/unit/mutationLifecycleWorkflow.integration.test.ts b/tests/unit/mutationLifecycleWorkflow.integration.test.ts index 3de1cdb..63acb86 100644 --- a/tests/unit/mutationLifecycleWorkflow.integration.test.ts +++ b/tests/unit/mutationLifecycleWorkflow.integration.test.ts @@ -222,6 +222,7 @@ describe("mutationLifecycleWorkflow integration", () => { shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: true, queuedCount: 1, @@ -232,6 +233,7 @@ describe("mutationLifecycleWorkflow integration", () => { shouldStartNextConfigMutation({ status: "connected", hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, hasActiveMutation: false, hasRestartBlockInProgress: false, queuedCount: 1,