mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Improve config toggle UX
This commit is contained in:
+17
-8
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user