Improve config toggle UX

This commit is contained in:
George Pickett
2026-02-24 12:16:02 -08:00
parent 8a645a2295
commit f642f9ca87
13 changed files with 182 additions and 31 deletions
+17 -8
View File
@@ -266,7 +266,12 @@ const AgentStudioPage = () => {
null
);
const enqueueConfigMutationRef = useRef<
(params: { kind: ConfigMutationKind; label: string; run: () => Promise<void> }) => Promise<void>
(params: {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) => Promise<void>
>((input) => Promise.reject(new Error(`Config mutation queue not ready for "${input.kind}".`)));
const approvalPausedRunIdByAgentRef = useRef<Map<string, string>>(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`
+2 -2
View File
@@ -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 ? <Sun className="h-[15px] w-[15px]" /> : <Moon className="h-[15px] w-[15px]" />}
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
</button>
);
};
@@ -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 (
<div className="w-full self-start">
<div className={`relative w-full ${ASSISTANT_MAX_WIDTH_DEFAULT_CLASS} ${ASSISTANT_GUTTER_CLASS}`}>
<div className="absolute left-[4px] top-[2px]">
<AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} />
</div>
<div className="flex items-center justify-between gap-3 py-0.5">
<div className="type-meta min-w-0 truncate font-mono text-foreground/90">
{name}
</div>
</div>
<div className="ui-chat-assistant-card mt-2">
<div className="text-[14px] leading-[1.65] text-foreground">{title}</div>
<div className="mt-2 font-mono text-[10px] tracking-[0.03em] text-muted-foreground/80">
Try describing a task, bug, or question to get started.
</div>
</div>
</div>
</div>
);
});
const AgentChatFinalItems = memo(function AgentChatFinalItems({
agentId,
name,
@@ -729,7 +761,12 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
</div>
) : null}
{!hasTranscriptContent ? (
<EmptyStatePanel title={emptyStateTitle} compact className="p-3 text-xs" />
<AssistantIntroCard
avatarSeed={avatarSeed}
avatarUrl={avatarUrl}
name={name}
title={emptyStateTitle}
/>
) : (
<>
<AgentChatFinalItems
@@ -196,6 +196,11 @@ const createInitialCronDraft = (): CronCreateDraft => ({
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<AgentPermissionsDraft>(
permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent))
);
const initialPermissionsDraft =
permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent));
const [permissionsBaselineValue, setPermissionsBaselineValue] =
useState<AgentPermissionsDraft>(initialPermissionsDraft);
const [permissionsDraftValue, setPermissionsDraftValue] =
useState<AgentPermissionsDraft>(initialPermissionsDraft);
const [permissionsSaving, setPermissionsSaving] = useState(false);
const [permissionsSaveState, setPermissionsSaveState] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const [permissionsSaveError, setPermissionsSaveError] = useState<string | null>(null);
const permissionsSaveTimerRef = useRef<number | null>(null);
const permissionsDraftAgentIdRef = useRef(agent.agentId);
const [expandedCronJobIds, setExpandedCronJobIds] = useState<Set<string>>(() => 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;
+5 -5
View File
@@ -37,10 +37,10 @@ export const HeaderBar = ({
}, [menuOpen]);
return (
<div className="glass-panel fade-up ui-panel ui-topbar relative z-[180] px-3.5 py-2">
<div className="grid items-center gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
<div className="glass-panel fade-up ui-panel ui-topbar relative z-[180] px-3.5 py-1.5">
<div className="grid items-center gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<p className="console-title type-page-title text-foreground">
<p className="console-title type-page-title text-[21px] leading-[1.08] text-foreground">
OpenClaw Studio
</p>
</div>
@@ -60,13 +60,13 @@ export const HeaderBar = ({
<div className="relative z-[210]" ref={menuRef}>
<button
type="button"
className="ui-btn-icon"
className="ui-btn-icon ui-btn-icon-sm"
data-testid="studio-menu-toggle"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
>
<Plug className="h-4 w-4" />
<Plug className="h-3.5 w-3.5" />
<span className="sr-only">Open studio menu</span>
</button>
{menuOpen ? (
@@ -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;
}
@@ -170,6 +170,7 @@ export type AgentConfigMutationLifecycleDeps = {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) => Promise<void>;
setQueuedBlock: () => void;
setMutatingBlock: () => void;
@@ -198,6 +199,7 @@ export type CreateAgentMutationLifecycleDeps = {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) => Promise<void>;
createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>;
setQueuedBlock: (params: { agentName: string; startedAt: number }) => void;
@@ -46,6 +46,7 @@ export type UseAgentSettingsMutationControllerParams = {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) => Promise<void>;
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
settingsRouteActive: boolean;
@@ -16,6 +16,7 @@ type QueuedConfigMutation = {
id: string;
kind: ConfigMutationKind;
label: string;
requiresIdleAgents: boolean;
run: () => Promise<void>;
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<void> }) =>
(params: {
kind: ConfigMutationKind;
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) =>
new Promise<void>((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,
@@ -31,6 +31,7 @@ export type UseGatewayConfigSyncControllerParams = {
kind: "repair-sandbox-tool-allowlist";
label: string;
run: () => Promise<void>;
requiresIdleAgents?: boolean;
}) => Promise<void>;
loadAgents: () => Promise<void>;
isDisconnectLikeError: (err: unknown) => boolean;
+56
View File
@@ -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, {
+20 -2
View File
@@ -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);
});
});
@@ -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,