From fbbcf6d0cc761a5a0f1e22ac4bf0ff69fdbe5436 Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Fri, 7 Aug 2026 12:19:51 -0400 Subject: [PATCH 1/4] feat(ui): agent update toasts + surface polish --- packages/ui/src/assets/style.css | 51 ++++++++++ packages/ui/src/components/WindowSideBar.tsx | 2 +- .../components/sidepanel/ChatSidePanel.tsx | 2 +- packages/ui/src/components/use-toast.ts | 7 ++ .../useAcpAgentUpdateNotifications.ts | 97 +++++++++++++++++++ packages/ui/src/routes/_main.tsx | 2 + 6 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/composables/useAcpAgentUpdateNotifications.ts diff --git a/packages/ui/src/assets/style.css b/packages/ui/src/assets/style.css index 894bb25fa..cf19b04a9 100644 --- a/packages/ui/src/assets/style.css +++ b/packages/ui/src/assets/style.css @@ -1302,3 +1302,54 @@ width: 100%; height: 100%; } + +/* Sonner toasts — Argos visual identity (gradient hairline card). + Kept unlayered so it wins over Tailwind/sonner defaults without weight churn. */ +[data-sonner-toast][data-styled="true"] { + --normal-bg: transparent !important; + --normal-text: var(--foreground) !important; + + background-image: linear-gradient( + 120deg, + color-mix(in oklab, var(--accent-500) 24%, transparent) 0%, + color-mix(in oklab, var(--accent-500) 8%, transparent) 42%, + transparent 74% + ) !important; + background-color: color-mix(in oklab, var(--background) 88%, transparent) !important; + backdrop-filter: blur(20px); + border: 1px solid color-mix(in oklab, var(--accent-500) 34%, var(--border-color-strong)) !important; + border-radius: var(--radius-lg) !important; + box-shadow: none !important; + color: var(--foreground) !important; +} + +[data-sonner-toast][data-styled="true"] [data-button] { + background: var(--accent-500) !important; + color: var(--accent-foreground) !important; + border-radius: var(--radius-sm) !important; + font-weight: 600; + height: 26px; + padding-left: 10px; + padding-right: 10px; + transition: background 160ms ease; +} + +[data-sonner-toast][data-styled="true"] [data-button]:hover { + background: var(--accent-600) !important; +} + +[data-sonner-toast][data-styled="true"] [data-cancel] { + color: var(--foreground) !important; + background: color-mix(in oklab, var(--foreground) 8%, transparent) !important; +} + +[data-sonner-toast][data-styled="true"] [data-close-button] { + background: color-mix(in oklab, var(--background) 88%, transparent) !important; + border-color: var(--border-color-strong) !important; + color: var(--ink-500) !important; +} + +[data-sonner-toast][data-styled="true"] [data-close-button]:hover { + background: var(--background) !important; + border-color: var(--border-color-strong) !important; +} diff --git a/packages/ui/src/components/WindowSideBar.tsx b/packages/ui/src/components/WindowSideBar.tsx index d554fb9f7..5059e879a 100644 --- a/packages/ui/src/components/WindowSideBar.tsx +++ b/packages/ui/src/components/WindowSideBar.tsx @@ -312,7 +312,7 @@ export default function WindowSideBar() { <>
diff --git a/packages/ui/src/components/sidepanel/ChatSidePanel.tsx b/packages/ui/src/components/sidepanel/ChatSidePanel.tsx index 456289028..bfab3b429 100644 --- a/packages/ui/src/components/sidepanel/ChatSidePanel.tsx +++ b/packages/ui/src/components/sidepanel/ChatSidePanel.tsx @@ -207,7 +207,7 @@ export function ChatSidePanel({ sessionId, workspacePath }: ChatSidePanelProps) > {sessionId && (
-
-
Tools
+
+
+
Tools
+

+ First-party tools this agent can use.{" "} + {form.orchestrationEnabled + ? "Toggle groups to enable or disable them in bulk." + : "Argos orchestration is off, so this agent can't use these tools yet."} +

+
{groupedTools.length === 0 ? (
No agent tools available.
) : ( -
- {groupedTools.map((group) => ( -
-
-
- {group.label} -
- setGroupEnabled(group, value)} - /> -
-
- {group.tools.map((tool) => ( - - ))} + + {group.label} + + {group.tools.length} tool{group.tools.length === 1 ? "" : "s"} + + {gateOff && ( + + Requires Argos orchestration + + )} + + setGroupEnabled(group, value)} + /> +
+ {isExpanded && ( +
+ {group.tools.map((tool, toolIndex) => { + const enabled = !gateOff && isToolEnabled(tool.function.name); + return ( +
0 ? "border-t border-border/60" : "" + } ${gateOff ? "opacity-60" : ""}`} + > + +
+
+ {tool.function.name} +
+
+ {tool.function.description || "No description provided."} +
+
+ setToolEnabled(tool.function.name, value)} + className="mt-0.5" + /> +
+ ); + })} +
+ )}
-
- ))} + ); + })}
)} @@ -1709,7 +1775,7 @@ export default function ArgosAgentsSettings() { value={form.systemPrompt} onChange={(e) => updateForm("systemPrompt", e.target.value)} className="min-h-35 font-mono text-xs" - placeholder="settings.argosAgents.systemPromptPlaceholder" + placeholder="Enter a system prompt to guide the agent's behavior. You can also select a saved system prompt using the button above." /> diff --git a/packages/ui/settings/components/ScheduledTasksSettings.tsx b/packages/ui/settings/components/ScheduledTasksSettings.tsx index 925ce6b6e..f4b03d086 100644 --- a/packages/ui/settings/components/ScheduledTasksSettings.tsx +++ b/packages/ui/settings/components/ScheduledTasksSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Icon } from "@iconify/react"; import { Button } from "#shadcn/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#shadcn/components/ui/collapsible"; @@ -62,6 +62,10 @@ export default function ScheduledTasksSettings() { const [recurringTimeValues, setRecurringTimeValues] = useState([]); const tasks = useMemo(() => settings?.tasks ?? [], [settings]); + const settingsRef = useRef(settings); + useEffect(() => { + settingsRef.current = settings; + }, [settings]); const enabledAgents = useMemo(() => agents.filter((a) => a.enabled), [agents]); const getModelLabel = useCallback( @@ -103,6 +107,7 @@ export default function ScheduledTasksSettings() { setSettings(nextSettings); setAgents(nextAgents); } catch (error) { + console.error("[ScheduledTasks] Failed to load settings:", error); toast({ title: "Operation failed", description: error instanceof Error ? error.message : String(error), @@ -126,6 +131,7 @@ export default function ScheduledTasksSettings() { }); setSettings(response.settings); } catch (error) { + console.error("[ScheduledTasks] Failed to persist task:", task.id, error); toast({ title: "Operation failed", description: error instanceof Error ? error.message : String(error), @@ -139,12 +145,12 @@ export default function ScheduledTasksSettings() { ); const commitTask = useCallback( - async (index: number) => { - const task = tasks[index]; + async (index: number, override?: ScheduledTask) => { + const task = override ?? settingsRef.current?.tasks[index]; if (!task) return; await persistTask(task); }, - [tasks, persistTask], + [persistTask], ); useEffect(() => { @@ -182,7 +188,12 @@ export default function ScheduledTasksSettings() { setSettings(response.settings); if (response.task) setOpenTaskIds((prev) => [...prev, response.task!.id]); } catch (error) { - toast({ title: "Operation failed", variant: "destructive" }); + console.error("[ScheduledTasks] Failed to add task:", error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); } finally { setIsSaving(false); } @@ -282,7 +293,12 @@ export default function ScheduledTasksSettings() { const response = await client.toggle(task.id, value); setSettings(response.settings); } catch (error) { - toast({ title: "Operation failed", variant: "destructive" }); + console.error("[ScheduledTasks] Failed to toggle task:", task.id, error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); } }} /> @@ -299,7 +315,12 @@ export default function ScheduledTasksSettings() { setSettings(response.settings); toast({ title: "Task executed", description: response.task.name }); } catch (error) { - toast({ title: "Operation failed", variant: "destructive" }); + console.error("[ScheduledTasks] Failed to run task:", task.id, error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); } finally { setFiringId(null); } @@ -321,7 +342,12 @@ export default function ScheduledTasksSettings() { const response = await client.remove(task.id); setSettings(response); } catch (error) { - toast({ title: "Operation failed", variant: "destructive" }); + console.error("[ScheduledTasks] Failed to delete task:", task.id, error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); } }} > @@ -385,7 +411,7 @@ export default function ScheduledTasksSettings() { tasks: settings.tasks.map((t, i) => (i === index ? { ...t, trigger } : t)), }; setSettings(next); - void commitTask(index); + void commitTask(index, next.tasks[index]); }} > @@ -446,7 +472,7 @@ export default function ScheduledTasksSettings() { ), }; setSettings(next); - void commitTask(index); + void commitTask(index, next.tasks[index]); }} > @@ -535,7 +561,7 @@ export default function ScheduledTasksSettings() { tasks: settings.tasks.map((t, i) => (i === index ? { ...t, action } : t)), }; setSettings(next); - void commitTask(index); + void commitTask(index, next.tasks[index]); }} > @@ -652,7 +678,7 @@ export default function ScheduledTasksSettings() { ), }; setSettings(next); - void commitTask(index); + void commitTask(index, next.tasks[index]); }} > @@ -726,7 +752,7 @@ export default function ScheduledTasksSettings() { ...prev, [task.id]: false, })); - void commitTask(index); + void commitTask(index, next.tasks[index]); }} /> diff --git a/packages/ui/src/assets/style.css b/packages/ui/src/assets/style.css index cf19b04a9..e63cb02f0 100644 --- a/packages/ui/src/assets/style.css +++ b/packages/ui/src/assets/style.css @@ -1303,53 +1303,57 @@ height: 100%; } -/* Sonner toasts — Argos visual identity (gradient hairline card). +/* Sonner toasts — dropdown-glass (frosted elevated card). + Mirrors the t3code dropdown-glass recipe: translucent popover surface, blur, + hairline foreground border, and a deep soft shadow for float elevation. Kept unlayered so it wins over Tailwind/sonner defaults without weight churn. */ [data-sonner-toast][data-styled="true"] { --normal-bg: transparent !important; --normal-text: var(--foreground) !important; - background-image: linear-gradient( - 120deg, - color-mix(in oklab, var(--accent-500) 24%, transparent) 0%, - color-mix(in oklab, var(--accent-500) 8%, transparent) 42%, - transparent 74% - ) !important; - background-color: color-mix(in oklab, var(--background) 88%, transparent) !important; - backdrop-filter: blur(20px); - border: 1px solid color-mix(in oklab, var(--accent-500) 34%, var(--border-color-strong)) !important; - border-radius: var(--radius-lg) !important; - box-shadow: none !important; + background: color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) 80%, transparent)) !important; + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent) !important; + border-radius: 8px !important; + box-shadow: 0 16px 40px -18px rgb(0 0 0 / 55%) !important; color: var(--foreground) !important; + font-weight: 500; +} + +.dark [data-sonner-toast][data-styled="true"] { + box-shadow: 0 18px 44px -18px rgb(0 0 0 / 80%) !important; } [data-sonner-toast][data-styled="true"] [data-button] { - background: var(--accent-500) !important; - color: var(--accent-foreground) !important; - border-radius: var(--radius-sm) !important; + background: var(--foreground) !important; + color: var(--background) !important; + border-radius: 9999px !important; + box-shadow: none !important; font-weight: 600; + font-size: 12px; height: 26px; - padding-left: 10px; - padding-right: 10px; - transition: background 160ms ease; + padding-left: 12px; + padding-right: 12px; + transition: opacity 160ms ease; } [data-sonner-toast][data-styled="true"] [data-button]:hover { - background: var(--accent-600) !important; + opacity: 0.85; } [data-sonner-toast][data-styled="true"] [data-cancel] { color: var(--foreground) !important; - background: color-mix(in oklab, var(--foreground) 8%, transparent) !important; + background: color-mix(in srgb, var(--foreground) 8%, transparent) !important; } [data-sonner-toast][data-styled="true"] [data-close-button] { - background: color-mix(in oklab, var(--background) 88%, transparent) !important; - border-color: var(--border-color-strong) !important; + background: color-mix(in srgb, var(--popover) 88%, transparent) !important; + border-color: color-mix(in srgb, var(--foreground) 10%, transparent) !important; color: var(--ink-500) !important; } [data-sonner-toast][data-styled="true"] [data-close-button]:hover { - background: var(--background) !important; - border-color: var(--border-color-strong) !important; + background: var(--popover) !important; + border-color: color-mix(in srgb, var(--foreground) 18%, transparent) !important; } diff --git a/packages/ui/src/components/AppBar.tsx b/packages/ui/src/components/AppBar.tsx index 78435c663..9867b5c01 100644 --- a/packages/ui/src/components/AppBar.tsx +++ b/packages/ui/src/components/AppBar.tsx @@ -18,6 +18,7 @@ export default function AppBar() { const upgrade = useUpgradeStore(); const [isMacOS, setIsMacOS] = useState(false); + const [isWindows, setIsWindows] = useState(false); const [isMaximized, setIsMaximized] = useState(false); const [isFullscreened, setIsFullscreened] = useState(false); const [stopListener, setStopListener] = useState<(() => void) | null>(null); @@ -50,6 +51,7 @@ export default function AppBar() { void upgrade.refreshStatus(); deviceClient.getDeviceInfo().then((deviceInfo) => { setIsMacOS(deviceInfo.platform === "darwin"); + setIsWindows(deviceInfo.platform === "win32"); }); void windowClient.getCurrentState().then((state) => { @@ -70,8 +72,12 @@ export default function AppBar() { const roundedClass = !isFullscreened && isMacOS ? "" : " rounded-t-none"; + // Windows uses the native window controls overlay (caption buttons drawn by the OS over + // the title bar). Only draw custom in-app buttons on Linux (frameless) and browser mode. + const showCustomWindowButtons = !isMacOS && !isWindows; + return ( -
+
{!isFullscreened && isMacOS &&
} {showUpdateButton && ( @@ -87,7 +93,7 @@ export default function AppBar() { )}
- {(!isMacOS || isBrowser) && ( + {(showCustomWindowButtons || isBrowser) && ( )} - {(!isMacOS || isBrowser) && ( + {(showCustomWindowButtons || isBrowser) && (
} > - + {sessionStore.groupMode === "project" ? "Group by Date" : "Group by Project"} @@ -485,7 +482,7 @@ export default function WindowSideBar() { /> } > - + New Chat @@ -559,7 +556,7 @@ export default function WindowSideBar() { > @@ -601,7 +598,7 @@ export default function WindowSideBar() { > diff --git a/packages/ui/src/components/WindowSideBarSessionItem.tsx b/packages/ui/src/components/WindowSideBarSessionItem.tsx index 513c74481..d71a0b400 100644 --- a/packages/ui/src/components/WindowSideBarSessionItem.tsx +++ b/packages/ui/src/components/WindowSideBarSessionItem.tsx @@ -1,6 +1,19 @@ import { useMemo } from "react"; import { Icon } from "@iconify/react"; import type { UISession } from "#/stores/ui/session"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#shadcn/components/ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "#shadcn/components/ui/context-menu"; type PinFeedbackMode = "pinning" | "unpinning"; type SessionItemRegion = "pinned" | "grouped"; @@ -89,90 +102,109 @@ export default function WindowSideBarSessionItem({ const shortcutBadgeTitle = shortcutBadgeLabel ? `Switch with ${shortcutBadgeLabel}` : ""; return ( -
onSelect(session)} - > - - -
- - - {titleSegments.map((segment, index) => - segment.match ? ( - - {segment.text} - - ) : ( - {segment.text} - ), - )} - - {isWorking && ( - - )} - + + onSelect(session)} + > +
+ + + {titleSegments.map((segment, index) => + segment.match ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + {isWorking && ( + + )} + - {statusIcon && ( - - - - )} -
+ {statusIcon && ( + + + + )} +
- + {shortcutBadgeVisible && shortcutBadgeLabel && ( + + {shortcutBadgeLabel} + + )} + + e.stopPropagation()} + /> + } + > + + + + onTogglePin(session)}> + + {pinActionLabel} + + onDelete(session)}> + + {deleteActionLabel} + + + + +
+ } > - {shortcutBadgeVisible && shortcutBadgeLabel ? ( - - {shortcutBadgeLabel} - - ) : ( - - )} - -
+ + onSelect(session)}>Open + + onTogglePin(session)}> + + {pinActionLabel} + + onDelete(session)}> + + {deleteActionLabel} + + + + ); } diff --git a/packages/ui/src/components/chat-input/McpIndicator.tsx b/packages/ui/src/components/chat-input/McpIndicator.tsx index 11abe1b1e..ec59ff1d0 100644 --- a/packages/ui/src/components/chat-input/McpIndicator.tsx +++ b/packages/ui/src/components/chat-input/McpIndicator.tsx @@ -29,7 +29,15 @@ type ToolGroup = { name: string; label: string; items: ToolGroupItem[] }; type SystemPromptMenuOption = { id: string; label: string; disabled?: boolean }; -const GROUP_ORDER = ["agent-filesystem", "agent-core", "agent-skills", "argos-settings", "yobrowser"]; +const GROUP_ORDER = [ + "agent-filesystem", + "agent-core", + "agent-skills", + "argos-settings", + "argos-orchestration", + "pi", + "yobrowser", +]; interface McpIndicatorProps { showSystemPromptSection?: boolean; @@ -108,6 +116,8 @@ export default function McpIndicator({ "agent-core": "Core", "agent-skills": "Skills", "argos-settings": "Settings", + "argos-orchestration": "Orchestration", + pi: "Pi", yobrowser: "Browser", }; return labels[serverName] ?? serverName; diff --git a/packages/ui/src/routes/_main.tsx b/packages/ui/src/routes/_main.tsx index 3b98ec845..db23d940b 100644 --- a/packages/ui/src/routes/_main.tsx +++ b/packages/ui/src/routes/_main.tsx @@ -509,7 +509,7 @@ function MainLayout() { return ( <> -
+
Date: Sun, 9 Aug 2026 23:46:21 -0400 Subject: [PATCH 3/4] fix: address PR #45 review comments Consolidate desktop scheduled-task normalize onto backend-core so the daily/weekly lastFiredAt floor applies on desktop too (was a separate, unfixed copy -> still infinite-looped). Omit WCO symbolColor in Windows forced-colors mode. Serialize per-task upserts in ScheduledTasksSettings. Queue ACP recheck when an event arrives mid-flight. Gate AppBar custom controls on platform detection to avoid a duplicate-control flash. Remove manual useMemo (React Compiler) and redundant sr-only labels, add a session-row keyboard handler, broaden piToolCatalog schema (edit.edits[] + recursive property type), fix MCP scope allowlist wording, and align the eventbus test mock with the presenter contract. Add recurrence-boundary + full Pi-tool regression tests. --- apps/daemon/src/host/piToolCatalog.ts | 29 ++- apps/daemon/test/daemonScheduledTasks.test.ts | 16 +- .../daemon/test/daemonToolDefinitions.test.ts | 37 ++- .../presenter/scheduledTasks/normalize.ts | 221 +----------------- .../main/presenter/windowPresenter/index.ts | 9 + .../test/main/eventbus/eventbus.test.ts | 1 + .../main/presenter/scheduledTasks.test.ts | 98 ++++++++ .../components/AgentExtensionPolicyPanel.tsx | 14 +- .../components/ArgosAgentsSettings.tsx | 19 +- .../components/ScheduledTasksSettings.tsx | 58 +++-- packages/ui/src/components/AppBar.tsx | 8 +- .../components/WindowSideBarSessionItem.tsx | 10 + .../useAcpAgentUpdateNotifications.ts | 13 +- 13 files changed, 270 insertions(+), 263 deletions(-) diff --git a/apps/daemon/src/host/piToolCatalog.ts b/apps/daemon/src/host/piToolCatalog.ts index f1c7133c1..efcce0ab8 100644 --- a/apps/daemon/src/host/piToolCatalog.ts +++ b/apps/daemon/src/host/piToolCatalog.ts @@ -1,8 +1,22 @@ import type { MCPToolDefinition } from "@argos/shared/types/core/mcp"; +// JSON-Schema-compatible property descriptor. `properties`/`items` can themselves +// describe nested objects (e.g. `edit.edits[]` with its own `properties` and +// `required`), so this is recursive rather than a flat `{ type, description }`. +type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "array" | "object" | "null"; + +type CatalogProperty = { + type: JsonSchemaType; + description: string; + items?: CatalogProperty; + properties?: Record; + required?: string[]; + enum?: (string | number)[]; +}; + type CatalogEntry = { description: string; - properties: Record; + properties: Record; required?: string[]; }; @@ -33,7 +47,18 @@ const PI_TOOL_CATALOG: Record = { path: { type: "string", description: "Path of the file to edit." }, edits: { type: "array", - items: { type: "object", description: "A single replacement: oldText to find and newText to replace it with." }, + items: { + type: "object", + description: "A single replacement: oldText to find and newText to replace it with.", + properties: { + oldText: { + type: "string", + description: "Exact text to find in the file. Must be unique and non-overlapping.", + }, + newText: { type: "string", description: "Text to replace the matched region with." }, + }, + required: ["oldText", "newText"], + }, description: "The edits to apply.", }, }, diff --git a/apps/daemon/test/daemonScheduledTasks.test.ts b/apps/daemon/test/daemonScheduledTasks.test.ts index a3f5d3fb7..085971346 100644 --- a/apps/daemon/test/daemonScheduledTasks.test.ts +++ b/apps/daemon/test/daemonScheduledTasks.test.ts @@ -91,7 +91,7 @@ describe("DaemonScheduledTasks", () => { }); }); - it("rejects draft prompt tasks because headless daemon has no desktop window", async () => { + it("falls back to notification-only for draft prompt tasks (headless daemon has no window)", async () => { const task = { id: "task-3", name: "Draft", @@ -108,6 +108,18 @@ describe("DaemonScheduledTasks", () => { } as const; const harness = createHarness([task as never]); - await expect(harness.runtime.fireNow("task-3")).rejects.toThrow("desktop window"); + // The headless daemon has no desktop window, so draft (non-auto-send) prompt + // tasks can't open a draft UI. They must not crash — they fall through to a + // notification so the user still sees the task fired. + const result = await harness.runtime.fireNow("task-3"); + + expect(harness.sessionRepository.create).not.toHaveBeenCalled(); + expect(harness.providerExecutionPort.sendMessage).not.toHaveBeenCalled(); + expect(harness.eventPublisher.publish).toHaveBeenCalledWith("scheduledTasks.notification", { + id: "scheduled:task-3", + title: "Draft", + body: "Open the draft", + }); + expect(result.task.lastFiredAt).toEqual(expect.any(Number)); }); }); diff --git a/apps/daemon/test/daemonToolDefinitions.test.ts b/apps/daemon/test/daemonToolDefinitions.test.ts index b46c4edaa..cfae63fef 100644 --- a/apps/daemon/test/daemonToolDefinitions.test.ts +++ b/apps/daemon/test/daemonToolDefinitions.test.ts @@ -104,13 +104,40 @@ describe("daemon tool definitions", () => { ) as { source?: string }; expect(orchestrationTool).toBeDefined(); expect(orchestrationTool.source).toBe("agent"); - const piTool = result.tools.find((tool) => (tool as { server?: { name?: string } }).server?.name === "pi") as { - function?: { name?: string }; + + // Every Pi tool should be present, tagged as an agent source, and carry a + // well-formed function schema (name + description + object parameters). + const piTools = result.tools.filter( + (tool) => (tool as { server?: { name?: string } }).server?.name === "pi", + ) as Array<{ source?: string; + function?: { name?: string; description?: string; parameters?: { type?: string; properties?: unknown } }; + }>; + const piByName = new Map(piTools.map((tool) => [tool.function?.name, tool])); + const expectedPiTools = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + expect(piTools.map((tool) => tool.function?.name).sort()).toEqual([...expectedPiTools].sort()); + for (const name of expectedPiTools) { + const tool = piByName.get(name); + expect(tool).toBeDefined(); + expect(tool!.source).toBe("agent"); + expect(tool!.function?.description).toBeTruthy(); + expect(tool!.function?.parameters?.type).toBe("object"); + expect(tool!.function?.parameters?.properties).toBeDefined(); + } + + // The edit tool's `edits[]` must describe its inner object shape so callers + // can construct valid calls (regression for the under-typed catalog entry). + const editTool = piByName.get("edit"); + const editParams = editTool!.function!.parameters as { + properties?: { + edits?: { type?: string; items?: { properties?: Record; required?: string[] } }; + }; }; - expect(piTool).toBeDefined(); - expect(piTool.function?.name).toBe("read"); - expect(piTool.source).toBe("agent"); + const edits = editParams.properties?.edits; + expect(edits?.type).toBe("array"); + expect(edits?.items?.properties?.oldText).toBeDefined(); + expect(edits?.items?.properties?.newText).toBeDefined(); + expect(edits?.items?.required).toEqual(["oldText", "newText"]); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/presenter/scheduledTasks/normalize.ts b/apps/desktop/src/main/presenter/scheduledTasks/normalize.ts index 79fa78bc6..033900418 100644 --- a/apps/desktop/src/main/presenter/scheduledTasks/normalize.ts +++ b/apps/desktop/src/main/presenter/scheduledTasks/normalize.ts @@ -1,210 +1,11 @@ -import { randomUUID } from "node:crypto"; -import log from "electron-log"; -import { z } from "zod"; -import { - SCHEDULED_TASKS_VERSION, - type ScheduledTask, - type ScheduledTaskAction, - type ScheduledTaskTrigger, - type ScheduledTasksSettings, - createDefaultScheduledTasksSettings, -} from "@argos/shared/scheduledTasks"; - -const TriggerSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("once"), firesAt: z.number().int().nonnegative() }), - z.object({ - kind: z.literal("daily"), - hour: z.number().int().min(0).max(23), - minute: z.number().int().min(0).max(59), - }), - z.object({ - kind: z.literal("weekly"), - dayOfWeek: z.number().int().min(0).max(6), - hour: z.number().int().min(0).max(23), - minute: z.number().int().min(0).max(59), - }), -]); - -const ActionSchema = z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("notify"), - title: z.string().max(200), - body: z.string().max(2000), - }), - z.object({ - kind: z.literal("prompt"), - title: z.string().max(200), - message: z.string().max(20000), - autoSend: z.boolean(), - agentId: z.string().optional(), - providerId: z.string().optional(), - modelId: z.string().optional(), - systemPrompt: z.string().max(20000).optional(), - }), -]); - -const ScheduledTaskSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1).max(200), - enabled: z.boolean(), - trigger: TriggerSchema, - action: ActionSchema, - createdAt: z.number().int().nonnegative(), - lastFiredAt: z.number().int().nonnegative().nullable(), -}); - -const LooseSchedulerSettingsSchema = z.object({ - version: z.unknown().optional(), - tasks: z.array(z.unknown()).optional(), -}); - -const sanitizeTrigger = (input: unknown): ScheduledTaskTrigger | null => { - const parsed = TriggerSchema.safeParse(input); - return parsed.success ? parsed.data : null; -}; - -const sanitizeAction = (input: unknown): ScheduledTaskAction | null => { - const parsed = ActionSchema.safeParse(input); - return parsed.success ? parsed.data : null; -}; - -const sanitizeTask = (input: unknown, fallbackIndex: number, now: number): ScheduledTask | null => { - if (!input || typeof input !== "object") { - return null; - } - const record = input as Record; - const trigger = sanitizeTrigger(record.trigger); - const action = sanitizeAction(record.action); - if (!trigger || !action) { - return null; - } - - const id = typeof record.id === "string" && record.id.trim().length > 0 ? record.id.trim() : randomUUID(); - const name = - typeof record.name === "string" && record.name.trim().length > 0 - ? record.name.trim().slice(0, 200) - : `Task ${fallbackIndex + 1}`; - const enabled = record.enabled === true; - const createdAt = - typeof record.createdAt === "number" && Number.isFinite(record.createdAt) && record.createdAt > 0 - ? record.createdAt - : now; - const lastFiredAt = - typeof record.lastFiredAt === "number" && Number.isFinite(record.lastFiredAt) && record.lastFiredAt > 0 - ? record.lastFiredAt - : null; - - const candidate = { id, name, enabled, trigger, action, createdAt, lastFiredAt }; - const parsed = ScheduledTaskSchema.safeParse(candidate); - return parsed.success ? parsed.data : null; -}; - -const makeUniqueTaskId = (id: string, seenIds: Set): string => { - if (!seenIds.has(id)) { - return id; - } - - let suffix = 2; - let nextId = `${id}-${suffix}`; - while (seenIds.has(nextId)) { - suffix += 1; - nextId = `${id}-${suffix}`; - } - return nextId; -}; - -export const normalizeScheduledTasksConfig = (input: unknown, now: number = Date.now()): ScheduledTasksSettings => { - const defaults = createDefaultScheduledTasksSettings(); - const parsed = LooseSchedulerSettingsSchema.safeParse(input); - if (!parsed.success) { - log.warn("[ScheduledTasks] Invalid config, using defaults:", parsed.error?.message); - return defaults; - } - - const rawTasks = Array.isArray(parsed.data.tasks) ? parsed.data.tasks : []; - const seenIds = new Set(); - const tasks = rawTasks.reduce((acc, candidate, index) => { - const sanitized = sanitizeTask(candidate, index, now); - if (sanitized) { - const id = makeUniqueTaskId(sanitized.id, seenIds); - seenIds.add(id); - acc.push(id === sanitized.id ? sanitized : { ...sanitized, id }); - } else { - log.warn(`[ScheduledTasks] Dropping malformed task at index ${index}`); - } - return acc; - }, []); - - return { - version: SCHEDULED_TASKS_VERSION, - tasks, - }; -}; - -const startOfMinute = (timestamp: number): number => { - const date = new Date(timestamp); - date.setSeconds(0, 0); - return date.getTime(); -}; - -const buildWallClockToday = (reference: number, hour: number, minute: number, dayOffset = 0): number => { - const date = new Date(reference); - date.setDate(date.getDate() + dayOffset); - date.setHours(hour, minute, 0, 0); - return date.getTime(); -}; - -/** - * Compute the next absolute timestamp at which `task` should fire, strictly - * after `after`. Returns `null` if the task can no longer fire (one-shot - * already fired or one-shot whose `firesAt` is in the past with respect to - * `after` — backfill handling is up to the caller via `lastFiredAt`). - */ -export const computeNextFireAt = (task: ScheduledTask, after: number): number | null => { - const trigger = task.trigger; - switch (trigger.kind) { - case "once": { - if (task.lastFiredAt) { - return null; - } - return trigger.firesAt > after ? trigger.firesAt : null; - } - case "daily": { - let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 0); - if (candidate <= after) { - candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 1); - } - return candidate; - } - case "weekly": { - const reference = new Date(after); - const currentDay = reference.getDay(); - let dayOffset = (trigger.dayOfWeek - currentDay + 7) % 7; - let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset); - if (candidate <= after) { - dayOffset += 7; - candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset); - } - return candidate; - } - default: - return null; - } -}; - -/** - * Returns true when a one-shot task should be backfilled (fired immediately - * on startup) because its `firesAt` is in the past and it has never been - * fired. Recurring tasks are never backfilled. - */ -export const shouldBackfillOneShot = (task: ScheduledTask, now: number): boolean => { - if (task.trigger.kind !== "once") { - return false; - } - if (task.lastFiredAt) { - return false; - } - return task.trigger.firesAt <= now; -}; - -export const startOfMinuteForTests = startOfMinute; +// Re-export the canonical scheduled-task helpers from @argos/backend-core so the +// desktop runtime shares one recurrence/normalization implementation with the +// daemon. Keeping a second copy here caused the daily/weekly `lastFiredAt` floor +// fix to drift between runtimes (the desktop scheduler kept refiring the same +// occurrence in a loop). Any future change only needs to land in backend-core. +export { + computeNextFireAt, + normalizeScheduledTasksConfig, + shouldBackfillOneShot, + startOfMinuteForTests, +} from "@argos/backend-core/scheduled/normalize"; diff --git a/apps/desktop/src/main/presenter/windowPresenter/index.ts b/apps/desktop/src/main/presenter/windowPresenter/index.ts index 676da633f..34fe3f250 100644 --- a/apps/desktop/src/main/presenter/windowPresenter/index.ts +++ b/apps/desktop/src/main/presenter/windowPresenter/index.ts @@ -54,6 +54,15 @@ function getTitleBarOverlayOptions(): Electron.TitleBarOverlayOptions | undefine if (process.platform !== "win32") { return undefined; } + // In Windows High Contrast / forced-colors mode, omit `symbolColor` so the OS + // paints the caption buttons with the user's accessibility palette. Setting an + // explicit symbol color in that mode overrides — and breaks — native contrast. + if (nativeTheme.shouldUseHighContrastColors || nativeTheme.inForcedColorsMode) { + return { + color: TITLEBAR_OVERLAY_COLOR, + height: TITLEBAR_OVERLAY_HEIGHT, + }; + } return { color: TITLEBAR_OVERLAY_COLOR, height: TITLEBAR_OVERLAY_HEIGHT, diff --git a/apps/desktop/test/main/eventbus/eventbus.test.ts b/apps/desktop/test/main/eventbus/eventbus.test.ts index 1a2cc86df..e01ef2e8b 100644 --- a/apps/desktop/test/main/eventbus/eventbus.test.ts +++ b/apps/desktop/test/main/eventbus/eventbus.test.ts @@ -18,6 +18,7 @@ describe("EventBus event bus", () => { sendToDefaultTab: vi.fn<(...args: any[]) => any>(), sendToWebContents: vi.fn<(...args: any[]) => any>().mockResolvedValue(true), sendToActiveTab: vi.fn<(...args: any[]) => any>().mockResolvedValue(true), + syncWindowTitleBarAppearance: vi.fn<(...args: any[]) => any>(), } as Partial as IWindowPresenter; // Mock TabPresenter diff --git a/apps/desktop/test/main/presenter/scheduledTasks.test.ts b/apps/desktop/test/main/presenter/scheduledTasks.test.ts index b57a0a202..06080f205 100644 --- a/apps/desktop/test/main/presenter/scheduledTasks.test.ts +++ b/apps/desktop/test/main/presenter/scheduledTasks.test.ts @@ -148,6 +148,104 @@ describe("computeNextFireAt", () => { const expected = new Date("2026-01-13T09:00:00"); expect(computeNextFireAt(task, reference.getTime())).toBe(expected.getTime()); }); + + // Regression: previously daily/weekly ignored lastFiredAt, so the 60s drift + // tolerance reselected the just-consumed slot → delay=0 → infinite refire loop. + // lastFiredAt now acts as a floor (effectiveAfter = max(after, lastFiredAt)). + it("skips the same-day daily slot when lastFiredAt is at or after that slot", () => { + // Slot is 09:30 today; lastFiredAt is 09:30:05 (5s after the slot). + const reference = new Date(); + reference.setHours(9, 30, 5, 0); + const slotToday = new Date(reference); + slotToday.setHours(9, 30, 0, 0); + const task = baseTask({ + id: "1", + name: "daily", + enabled: true, + trigger: { kind: "daily", hour: 9, minute: 30 }, + action: { kind: "notify", title: "t", body: "b" }, + createdAt: 0, + lastFiredAt: reference.getTime(), + }); + + const next = computeNextFireAt(task, reference.getTime()); + expect(next).not.toBeNull(); + expect(next!).toBeGreaterThan(reference.getTime()); + // Must not be the same-day slot that already fired. + expect(next).not.toBe(slotToday.getTime()); + }); + + it("treats lastFiredAt as a floor even when after is earlier (daily)", () => { + // after is 09:29 but lastFiredAt is 09:30:05 → effectiveAfter wins → next day. + const after = new Date(); + after.setHours(9, 29, 0, 0); + const lastFired = new Date(); + lastFired.setHours(9, 30, 5, 0); + const task = baseTask({ + id: "1", + name: "daily", + enabled: true, + trigger: { kind: "daily", hour: 9, minute: 30 }, + action: { kind: "notify", title: "t", body: "b" }, + createdAt: 0, + lastFiredAt: lastFired.getTime(), + }); + + const next = computeNextFireAt(task, after.getTime()); + expect(next).not.toBeNull(); + expect(next!).toBeGreaterThan(lastFired.getTime()); + }); + + it("skips the just-fired weekly slot and advances to the next week", () => { + // Tuesday 09:00:05, just fired the Tuesday 09:00 slot → next is next Tuesday 09:00. + const reference = new Date("2026-01-06T09:00:05"); + expect(reference.getDay()).toBe(2); + const task = baseTask({ + id: "1", + name: "weekly", + enabled: true, + trigger: { kind: "weekly", dayOfWeek: 2, hour: 9, minute: 0 }, + action: { kind: "notify", title: "t", body: "b" }, + createdAt: 0, + lastFiredAt: reference.getTime(), + }); + + const next = computeNextFireAt(task, reference.getTime()); + const expected = new Date("2026-01-13T09:00:00"); + expect(next).toBe(expected.getTime()); + }); + + it("never returns a candidate at or before lastFiredAt for daily or weekly", () => { + const dailyTask = baseTask({ + id: "d", + name: "daily", + enabled: true, + trigger: { kind: "daily", hour: 9, minute: 30 }, + action: { kind: "notify", title: "t", body: "b" }, + createdAt: 0, + lastFiredAt: new Date("2026-01-06T09:30:00").getTime(), + }); + const weeklyTask = baseTask({ + id: "w", + name: "weekly", + enabled: true, + trigger: { kind: "weekly", dayOfWeek: 2, hour: 9, minute: 30 }, + action: { kind: "notify", title: "t", body: "b" }, + createdAt: 0, + lastFiredAt: new Date("2026-01-06T09:30:00").getTime(), + }); + + // `after` earlier than, equal to, and later than lastFiredAt. + for (const afterOffset of [-60_000, 0, 60_000]) { + const after = dailyTask.lastFiredAt! + afterOffset; + const dailyNext = computeNextFireAt(dailyTask, after); + const weeklyNext = computeNextFireAt(weeklyTask, after); + expect(dailyNext).not.toBeNull(); + expect(weeklyNext).not.toBeNull(); + expect(dailyNext!).toBeGreaterThan(dailyTask.lastFiredAt!); + expect(weeklyNext!).toBeGreaterThan(weeklyTask.lastFiredAt!); + } + }); }); describe("shouldBackfillOneShot", () => { diff --git a/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx b/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx index edb8fdaf6..26417be74 100644 --- a/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx +++ b/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Checkbox } from "#shadcn/components/ui/checkbox"; import { Button } from "#shadcn/components/ui/button"; import { createMcpClient } from "#api/McpClient"; @@ -43,9 +43,8 @@ function PolicyScopeList({ onClear: () => void; disabled?: boolean; }) { - const selectedSet = useMemo( - () => new Set(selectedIds === undefined ? items.map((item) => item.id) : normalizeSelection(selectedIds)), - [selectedIds, items], + const selectedSet = new Set( + selectedIds === undefined ? items.map((item) => item.id) : normalizeSelection(selectedIds), ); const selectedCount = selectedIds === undefined ? items.length : selectedSet.size; const scopeLabel = @@ -98,7 +97,7 @@ export default function AgentExtensionPolicyPanel({ onChange, disabled = false, }: AgentExtensionPolicyPanelProps) { - const mcpClient = useMemo(() => createMcpClient(), []); + const mcpClient = createMcpClient(); const [loading, setLoading] = useState(true); const [mcpServers, setMcpServers] = useState< Array<{ id: string; label: string; pluginId?: string; source?: string; sourceId?: string }> @@ -153,7 +152,8 @@ export default function AgentExtensionPolicyPanel({ if (checked) { const explicit = normalizeSelection(current); const next = Array.from(new Set([...explicit, itemId])); - if (allIds.every((id) => next.includes(id))) { + const nextSet = new Set(next); + if (allIds.every((id) => nextSet.has(id))) { updateValue({ ...normalizedValue, enabledMcpServerIds: undefined }); return; } @@ -170,7 +170,7 @@ export default function AgentExtensionPolicyPanel({
MCP scope

Checked servers are available to this agent. Uncheck when everything is allowed to create an explicit - blocklist. + allowlist.

diff --git a/packages/ui/settings/components/ArgosAgentsSettings.tsx b/packages/ui/settings/components/ArgosAgentsSettings.tsx index b97a5a8e9..242a01933 100644 --- a/packages/ui/settings/components/ArgosAgentsSettings.tsx +++ b/packages/ui/settings/components/ArgosAgentsSettings.tsx @@ -1012,7 +1012,6 @@ export default function ArgosAgentsSettings() { type="button" className="w-full rounded-xl border border-accent-400/40 bg-accent-400/10 px-3 py-2 text-left" > - Unnamed Agent
{newAgentName.trim() || "Unnamed Agent"}
(
- {field.key === "visionModel" ? ( - <> - Vision model - Vision model - - ) : field.key === "imageGenerationModel" ? ( - <> - Image generation model - Image generation model - - ) : ( - field.label - )} + {field.key === "visionModel" + ? "Vision model" + : field.key === "imageGenerationModel" + ? "Image generation model" + : field.label}
settings?.tasks ?? [], [settings]); const settingsRef = useRef(settings); + // Per-task promise chain that serializes upsert requests so concurrent edits to + // the same task can't interleave or apply out of order. See `persistTask`. + const persistChainRef = useRef>>(new Map()); useEffect(() => { settingsRef.current = settings; }, [settings]); @@ -120,26 +123,43 @@ export default function ScheduledTasksSettings() { const persistTask = useCallback( async (task: ScheduledTask) => { - setIsSaving(true); - try { - const response = await client.upsert({ - id: task.id, - name: task.name, - enabled: task.enabled, - trigger: structuredClone(task.trigger), - action: structuredClone(task.action), + // Serialize upserts per task id. Two edits landing on the same task (e.g. a + // field blur racing a select change) would otherwise send two snapshots and + // an out-of-order response could restore older fields. Each task chains onto + // the previous in-flight request so responses apply in submission order. + const previous = persistChainRef.current.get(task.id) ?? Promise.resolve(); + const run = previous + .catch(() => {}) + .then(async () => { + setIsSaving(true); + try { + const response = await client.upsert({ + id: task.id, + name: task.name, + enabled: task.enabled, + trigger: structuredClone(task.trigger), + action: structuredClone(task.action), + }); + setSettings(response.settings); + } catch (error) { + console.error("[ScheduledTasks] Failed to persist task:", task.id, error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsSaving(false); + } }); - setSettings(response.settings); - } catch (error) { - console.error("[ScheduledTasks] Failed to persist task:", task.id, error); - toast({ - title: "Operation failed", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsSaving(false); - } + persistChainRef.current.set(task.id, run); + // Clear the chain entry once settled so the map doesn't grow unbounded. + void run.finally(() => { + if (persistChainRef.current.get(task.id) === run) { + persistChainRef.current.delete(task.id); + } + }); + return run; }, [client, toast], ); diff --git a/packages/ui/src/components/AppBar.tsx b/packages/ui/src/components/AppBar.tsx index 9867b5c01..73d48f9b2 100644 --- a/packages/ui/src/components/AppBar.tsx +++ b/packages/ui/src/components/AppBar.tsx @@ -17,8 +17,8 @@ export default function AppBar() { const langStore = useLanguageStore(); const upgrade = useUpgradeStore(); - const [isMacOS, setIsMacOS] = useState(false); - const [isWindows, setIsWindows] = useState(false); + const [isMacOS, setIsMacOS] = useState(null); + const [isWindows, setIsWindows] = useState(null); const [isMaximized, setIsMaximized] = useState(false); const [isFullscreened, setIsFullscreened] = useState(false); const [stopListener, setStopListener] = useState<(() => void) | null>(null); @@ -74,7 +74,9 @@ export default function AppBar() { // Windows uses the native window controls overlay (caption buttons drawn by the OS over // the title bar). Only draw custom in-app buttons on Linux (frameless) and browser mode. - const showCustomWindowButtons = !isMacOS && !isWindows; + // Gate on platform detection completing (null = still loading) so native Windows/macOS + // renders don't flash custom controls before getDeviceInfo() resolves. + const showCustomWindowButtons = isMacOS === false && isWindows === false; return (
diff --git a/packages/ui/src/components/WindowSideBarSessionItem.tsx b/packages/ui/src/components/WindowSideBarSessionItem.tsx index d71a0b400..91efde56d 100644 --- a/packages/ui/src/components/WindowSideBarSessionItem.tsx +++ b/packages/ui/src/components/WindowSideBarSessionItem.tsx @@ -116,7 +116,17 @@ export default function WindowSideBarSessionItem({ data-active={String(active)} data-session-region={region} data-session-id={session.id} + role="button" + tabIndex={0} + aria-label={session.title || "Open session"} + aria-pressed={active} onClick={() => onSelect(session)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(session); + } + }} >
(); let checkInFlight = false; +let recheckRequested = false; const getUpdateAvailableAgents = (agents: AcpRegistryAgent[]): AcpRegistryAgent[] => agents.filter( @@ -20,7 +21,13 @@ const getUpdateAvailableAgents = (agents: AcpRegistryAgent[]): AcpRegistryAgent[ ); async function checkForAgentUpdates(): Promise { - if (checkInFlight) return; + // If a check is already running, remember that another was requested so we run + // exactly one follow-up afterwards — a config event arriving mid-check could + // carry a newer registry version the in-flight check hasn't seen yet. + if (checkInFlight) { + recheckRequested = true; + return; + } checkInFlight = true; try { @@ -71,6 +78,10 @@ async function checkForAgentUpdates(): Promise { console.warn("[Agents] Failed to check for ACP agent updates:", error); } finally { checkInFlight = false; + if (recheckRequested) { + recheckRequested = false; + void checkForAgentUpdates(); + } } } From 96f1a9ddff92572d27553fdc2340b81d5c24bbb5 Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Sun, 9 Aug 2026 23:57:34 -0400 Subject: [PATCH 4/4] fix: serialize all scheduled-task mutations Replace the per-task upsert chain and isSaving boolean with a single ordered mutation queue that covers every settings-mutating operation (upsert, toggle, remove, fireNow) and a pending-operation counter for the Saving indicator. Out-of-order responses across different tasks or operations can no longer restore older fields, and concurrent saves no longer flip the indicator off early. Uses promise chaining instead of try/finally so React Compiler can still optimize the component. --- .../components/ScheduledTasksSettings.tsx | 201 ++++++++---------- 1 file changed, 90 insertions(+), 111 deletions(-) diff --git a/packages/ui/settings/components/ScheduledTasksSettings.tsx b/packages/ui/settings/components/ScheduledTasksSettings.tsx index 5867ebfef..b6b93af42 100644 --- a/packages/ui/settings/components/ScheduledTasksSettings.tsx +++ b/packages/ui/settings/components/ScheduledTasksSettings.tsx @@ -54,7 +54,10 @@ export default function ScheduledTasksSettings() { const [settings, setSettings] = useState(null); const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); + // Counter of in-flight mutations. A single boolean races when two operations + // (e.g. toggling one task while saving another) overlap — the first to finish + // would flip the indicator off while the second is still pending. + const [pendingMutations, setPendingMutations] = useState(0); const [firingId, setFiringId] = useState(null); const [modelPickerOpen, setModelPickerOpen] = useState>({}); const [openTaskIds, setOpenTaskIds] = useState([]); @@ -63,9 +66,11 @@ export default function ScheduledTasksSettings() { const tasks = useMemo(() => settings?.tasks ?? [], [settings]); const settingsRef = useRef(settings); - // Per-task promise chain that serializes upsert requests so concurrent edits to - // the same task can't interleave or apply out of order. See `persistTask`. - const persistChainRef = useRef>>(new Map()); + // Single ordered queue for EVERY settings-mutating operation (upsert, toggle, + // remove, fireNow). Each op chains onto the previous one so complete-settings + // responses are applied in submission order — an out-of-order response can no + // longer restore older fields across different tasks or operation types. + const mutationQueueRef = useRef | null>(null); useEffect(() => { settingsRef.current = settings; }, [settings]); @@ -103,72 +108,78 @@ export default function ScheduledTasksSettings() { ); }, [tasks]); - const loadSettings = useCallback(async () => { - setIsLoading(true); - try { - const [nextSettings, nextAgents] = await Promise.all([client.list(), configClient.listAgents()]); - setSettings(nextSettings); - setAgents(nextAgents); - } catch (error) { - console.error("[ScheduledTasks] Failed to load settings:", error); - toast({ - title: "Operation failed", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }, [client, configClient, toast]); - - const persistTask = useCallback( - async (task: ScheduledTask) => { - // Serialize upserts per task id. Two edits landing on the same task (e.g. a - // field blur racing a select change) would otherwise send two snapshots and - // an out-of-order response could restore older fields. Each task chains onto - // the previous in-flight request so responses apply in submission order. - const previous = persistChainRef.current.get(task.id) ?? Promise.resolve(); + // Run a settings-mutating operation through the single ordered queue. Errors + // are surfaced via toast and logged with `label`; the pending counter is + // always balanced via promise chaining (no try/finally, so React Compiler can + // still optimize this component). Returns fn's result on success. + const runMutation = useCallback( + (label: string, fn: () => Promise): Promise => { + setPendingMutations((n) => n + 1); + const previous = mutationQueueRef.current ?? Promise.resolve(); + // A failed previous op must not block this one — swallow its rejection. const run = previous .catch(() => {}) - .then(async () => { - setIsSaving(true); - try { - const response = await client.upsert({ - id: task.id, - name: task.name, - enabled: task.enabled, - trigger: structuredClone(task.trigger), - action: structuredClone(task.action), - }); - setSettings(response.settings); - } catch (error) { - console.error("[ScheduledTasks] Failed to persist task:", task.id, error); + .then(fn) + .then( + (result) => { + setPendingMutations((n) => Math.max(0, n - 1)); + return result; + }, + (error: unknown) => { + console.error(`[ScheduledTasks] ${label} failed:`, error); toast({ title: "Operation failed", description: error instanceof Error ? error.message : String(error), variant: "destructive", }); - } finally { - setIsSaving(false); - } - }); - persistChainRef.current.set(task.id, run); - // Clear the chain entry once settled so the map doesn't grow unbounded. - void run.finally(() => { - if (persistChainRef.current.get(task.id) === run) { - persistChainRef.current.delete(task.id); - } - }); + setPendingMutations((n) => Math.max(0, n - 1)); + throw error; + }, + ); + mutationQueueRef.current = run.catch(() => {}); return run; }, - [client, toast], + [toast], + ); + + const loadSettings = useCallback(() => { + setIsLoading(true); + return Promise.all([client.list(), configClient.listAgents()]) + .then(([nextSettings, nextAgents]) => { + setSettings(nextSettings); + setAgents(nextAgents); + }) + .catch((error: unknown) => { + console.error("[ScheduledTasks] Failed to load settings:", error); + toast({ + title: "Operation failed", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + }) + .finally(() => setIsLoading(false)); + }, [client, configClient, toast]); + + const persistTask = useCallback( + (task: ScheduledTask) => + runMutation(`persist task ${task.id}`, async () => { + const response = await client.upsert({ + id: task.id, + name: task.name, + enabled: task.enabled, + trigger: structuredClone(task.trigger), + action: structuredClone(task.action), + }); + setSettings(response.settings); + }), + [client, runMutation], ); const commitTask = useCallback( - async (index: number, override?: ScheduledTask) => { + (index: number, override?: ScheduledTask) => { const task = override ?? settingsRef.current?.tasks[index]; - if (!task) return; - await persistTask(task); + if (!task) return Promise.resolve(); + return persistTask(task); }, [persistTask], ); @@ -176,7 +187,6 @@ export default function ScheduledTasksSettings() { useEffect(() => { void loadSettings(); }, [loadSettings]); - useEffect(() => { refreshFormBuffers(); }, [tasks, refreshFormBuffers]); @@ -190,15 +200,14 @@ export default function ScheduledTasksSettings() { actions={ settings && !isLoading ? ( <> - {isSaving && ( + {pendingMutations > 0 && ( Saving )}