diff --git a/apps/daemon/src/dispatch/daemonDispatcher.ts b/apps/daemon/src/dispatch/daemonDispatcher.ts index 955205d4f..ef5a61df3 100644 --- a/apps/daemon/src/dispatch/daemonDispatcher.ts +++ b/apps/daemon/src/dispatch/daemonDispatcher.ts @@ -26,6 +26,7 @@ import type { import type { IConfigPresenter } from "@argos/shared/presenter"; import { resolveDaemonVersion } from "../version"; import { diagnoseDaemonSchema, repairDaemonSchema } from "../host/daemonSchemaDiagnostics"; +import { getPiToolDefinitions } from "../host/piToolCatalog"; import type { IEventPublisher, ProviderExecutionPort, @@ -787,6 +788,9 @@ export function createDaemonDispatcher( }; }, environmentId = "unknown", + orchestrationRuntime?: { + definitions(): unknown[]; + }, ): RouteDispatcher { const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter)); const runtime: { @@ -1730,8 +1734,13 @@ export function createDaemonDispatcher( if (route === toolsListDefinitionsRoute.name) { const input = toolsListDefinitionsRoute.input.parse(rawInput); + const orchestrationTools = orchestrationRuntime?.definitions() ?? []; return toolsListDefinitionsRoute.output.parse({ - tools: await mcpRuntime.listToolDefinitions(input.enabledMcpTools), + tools: [ + ...(await mcpRuntime.listToolDefinitions(input.enabledMcpTools)), + ...getPiToolDefinitions(), + ...orchestrationTools, + ], }); } diff --git a/apps/daemon/src/host/daemonScheduledTasks.ts b/apps/daemon/src/host/daemonScheduledTasks.ts index ad4551790..33c07c549 100644 --- a/apps/daemon/src/host/daemonScheduledTasks.ts +++ b/apps/daemon/src/host/daemonScheduledTasks.ts @@ -22,10 +22,7 @@ class DaemonScheduledTasksNotificationPresenter { } class DaemonScheduledTasksWindowPresenter { - readonly mainWindow = { - id: -1, - isDestroyed: () => false, - }; + readonly mainWindow = null; sendToWindow(): never { throw new Error("Scheduled task prompt drafts require a desktop window"); diff --git a/apps/daemon/src/host/piToolCatalog.ts b/apps/daemon/src/host/piToolCatalog.ts new file mode 100644 index 000000000..efcce0ab8 --- /dev/null +++ b/apps/daemon/src/host/piToolCatalog.ts @@ -0,0 +1,132 @@ +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; + required?: string[]; +}; + +const PI_TOOL_CATALOG: Record = { + read: { + description: + "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. Text output is truncated to 2000 lines or 50KB; use offset/limit for large files.", + properties: { + path: { type: "string", description: "Path to the file to read." }, + offset: { type: "number", description: "Offset into the file (in lines) to start reading from." }, + limit: { type: "number", description: "Maximum number of lines to read." }, + }, + required: ["path"], + }, + bash: { + description: + "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to the last 2000 lines or 50KB; optionally provide a timeout in seconds.", + properties: { + command: { type: "string", description: "The bash command to execute." }, + timeout: { type: "number", description: "Optional timeout in seconds." }, + }, + required: ["command"], + }, + edit: { + description: + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the file. Merge overlapping changes into one edit.", + properties: { + 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.", + 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.", + }, + }, + required: ["path", "edits"], + }, + write: { + description: + "Write content to a file, creating it if it doesn't exist and overwriting if it does. Automatically creates parent directories.", + properties: { + path: { type: "string", description: "Path of the file to write." }, + content: { type: "string", description: "Full content to write to the file." }, + }, + required: ["path", "content"], + }, + grep: { + description: + "Search file contents for a pattern, returning matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB.", + properties: { + pattern: { type: "string", description: "Pattern to search for." }, + path: { type: "string", description: "File or directory to search." }, + glob: { type: "string", description: "Optional glob to filter files." }, + ignoreCase: { type: "boolean", description: "Whether to ignore case." }, + literal: { type: "boolean", description: "Match literally rather than as a regex." }, + context: { type: "number", description: "Lines of context to include around matches." }, + limit: { type: "number", description: "Maximum number of matches." }, + }, + required: ["pattern", "path"], + }, + find: { + description: + "Search for files by glob pattern, returning paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB.", + properties: { + pattern: { type: "string", description: "Glob pattern to match." }, + path: { type: "string", description: "Directory to search." }, + limit: { type: "number", description: "Maximum number of results." }, + }, + required: ["pattern", "path"], + }, + ls: { + description: + "List directory contents, sorted alphabetically with a '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB.", + properties: { + path: { type: "string", description: "Directory to list." }, + limit: { type: "number", description: "Maximum number of entries." }, + }, + required: ["path"], + }, +}; + +const PI_SERVER = { + name: "pi", + icons: "", + description: "Pi coding-agent built-in tools", +} as const; + +export function getPiToolDefinitions(): MCPToolDefinition[] { + return Object.entries(PI_TOOL_CATALOG).map(([name, entry]) => ({ + type: "function", + source: "agent", + function: { + name, + description: entry.description, + parameters: { + type: "object", + properties: entry.properties, + required: entry.required, + }, + }, + server: { ...PI_SERVER }, + })); +} diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index a240c0e5e..6d0a2ae77 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -742,6 +742,7 @@ export async function startDaemon(options?: { providerImportService, db, environmentId, + orchestrationRuntime, ); setRouteDispatcher(dispatcher); diff --git a/apps/daemon/src/transport/http.ts b/apps/daemon/src/transport/http.ts index fa5d09998..e157c3e84 100644 --- a/apps/daemon/src/transport/http.ts +++ b/apps/daemon/src/transport/http.ts @@ -57,6 +57,7 @@ export async function dispatchRoute(route: string, input: unknown): Promise { }); }); - 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 ed8b77350..cfae63fef 100644 --- a/apps/daemon/test/daemonToolDefinitions.test.ts +++ b/apps/daemon/test/daemonToolDefinitions.test.ts @@ -39,18 +39,107 @@ describe("daemon tool definitions", () => { agentWorkspacePath: "/tmp/project", conversationId: "session-1", }), - ).resolves.toEqual({ - tools: [ - expect.objectContaining({ - name: "test-tool", - description: "Test tool", - enabledMcpTools: ["server-a"], - }), - ], - }); + ).resolves.toEqual( + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "test-tool", + description: "Test tool", + enabledMcpTools: ["server-a"], + }), + ]), + }), + ); expect(mcpRuntime.listToolDefinitions).toHaveBeenCalledWith(["server-a"]); } finally { await rm(root, { recursive: true, force: true }); } }); + + it("appends orchestration tools to the daemon tool definitions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "argos-daemon-tools-orchestration-")); + try { + const configPresenter = new DaemonConfigPresenter(path.join(root, "config"), path.join(root, "data")); + const mcpRuntime = { + listToolDefinitions: vi.fn(async () => [ + { + name: "mcp-tool", + description: "MCP tool", + server: { name: "server-a" }, + }, + ]), + }; + const orchestrationRuntime = { + definitions: vi.fn(() => [ + { + source: "agent", + function: { name: "argos_projects_list", description: "List projects." }, + server: { name: "argos-orchestration", description: "First-party tools" }, + }, + ]), + }; + + const dispatcher = createDaemonDispatcher( + configPresenter as any, + undefined, + undefined, + undefined, + undefined, + mcpRuntime as any, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "test-env", + orchestrationRuntime as any, + ); + const result = (await dispatcher(toolsListDefinitionsRoute.name, {})) as { tools: unknown[] }; + expect(result.tools).toContainEqual(expect.objectContaining({ name: "mcp-tool" })); + const orchestrationTool = result.tools.find( + (tool) => (tool as { server?: { name?: string } }).server?.name === "argos-orchestration", + ) as { source?: string }; + expect(orchestrationTool).toBeDefined(); + expect(orchestrationTool.source).toBe("agent"); + + // 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[] } }; + }; + }; + 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/configPresenter/index.ts b/apps/desktop/src/main/presenter/configPresenter/index.ts index a4c8acfeb..3228b8288 100644 --- a/apps/desktop/src/main/presenter/configPresenter/index.ts +++ b/apps/desktop/src/main/presenter/configPresenter/index.ts @@ -2838,6 +2838,12 @@ export class ConfigPresenter implements IConfigPresenter { } // Listen for system theme changes nativeTheme.on("updated", () => { + // Re-sync the Windows window controls overlay symbol colors when the theme flips + try { + presenter.windowPresenter.syncWindowTitleBarAppearance(); + } catch (error) { + console.error("Failed to sync window controls overlay theme:", error); + } // Only notify the renderer of system theme changes when the theme is set to "system" if (nativeTheme.themeSource === "system") { eventBus.sendToMain(SYSTEM_EVENTS.SYSTEM_THEME_UPDATED, nativeTheme.shouldUseDarkColors); @@ -2854,6 +2860,12 @@ export class ConfigPresenter implements IConfigPresenter { async setTheme(theme: "dark" | "light" | "system"): Promise { nativeTheme.themeSource = theme; this.setSetting("appTheme", theme); + // Re-sync the Windows native window controls overlay after an explicit theme change + try { + presenter.windowPresenter.syncWindowTitleBarAppearance(); + } catch (error) { + console.error("Failed to sync window title bar appearance:", error); + } // Notify all windows that the theme has changed eventBus.send(CONFIG_EVENTS.THEME_CHANGED, SendTarget.ALL_WINDOWS, theme); diff --git a/apps/desktop/src/main/presenter/hooksNotifications/config.ts b/apps/desktop/src/main/presenter/hooksNotifications/config.ts index cb47c5400..bb2c147bc 100644 --- a/apps/desktop/src/main/presenter/hooksNotifications/config.ts +++ b/apps/desktop/src/main/presenter/hooksNotifications/config.ts @@ -80,6 +80,9 @@ export const normalizeHooksNotificationsConfig = (input: unknown): HooksNotifica } const defaults = createDefaultHooksNotificationsConfig(); + if (input === undefined || input === null) { + return defaults; + } const parsed = HooksNotificationsSchema.safeParse(input); if (!parsed.success) { log.warn("[HooksNotifications] Invalid config, using defaults:", parsed.error?.message); 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 9484f211f..34fe3f250 100644 --- a/apps/desktop/src/main/presenter/windowPresenter/index.ts +++ b/apps/desktop/src/main/presenter/windowPresenter/index.ts @@ -1,5 +1,13 @@ // src\main\presenter\windowPresenter\index.ts -import { BrowserWindow, shell, nativeImage, ipcMain, screen, webContents as electronWebContents } from "electron"; +import { + BrowserWindow, + shell, + nativeImage, + nativeTheme, + ipcMain, + screen, + webContents as electronWebContents, +} from "electron"; import icon from "../../../../resources/icon.png?asset"; // App icon (macOS/Linux) import iconWin from "../../../../resources/icon.ico?asset"; // App icon (Windows) import { is } from "@electron-toolkit/utils"; // Electron utilities @@ -33,6 +41,35 @@ type PendingSettingsMessage = { args: unknown[]; }; +// Window Controls Overlay (WCO): on Windows the native caption buttons are drawn by +// Chromium into the top-right of the web contents. The overlay height matches the AppBar +// (h-9 = 36px); the overlay color is effectively transparent so the buttons float directly +// on the sidebar-toned AppBar surface. Symbol colors mirror the sidebar-foreground token. +const TITLEBAR_OVERLAY_HEIGHT = 36; +const TITLEBAR_OVERLAY_COLOR = "#01000000"; // "#00000000" renders black on some platforms +const TITLEBAR_OVERLAY_SYMBOL_LIGHT = "#43434c"; // sidebar foreground (light) oklch(0.38 0 0) +const TITLEBAR_OVERLAY_SYMBOL_DARK = "#b9b9c0"; // sidebar foreground (dark) oklch(0.78 0 0) + +function getTitleBarOverlayOptions(): Electron.TitleBarOverlayOptions | undefined { + 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, + symbolColor: nativeTheme.shouldUseDarkColors ? TITLEBAR_OVERLAY_SYMBOL_DARK : TITLEBAR_OVERLAY_SYMBOL_LIGHT, + }; +} + /** * Window Presenter, responsible for managing all BrowserWindow instances and their lifecycles. * Including creation, destruction, minimization, maximization, hiding, showing, focus management, and interaction with tabs. @@ -635,14 +672,19 @@ export class WindowPresenter implements IWindowPresenter { show: false, // Hide until ready-to-show to avoid a white flash autoHideMenuBar: true, // Hide the menu bar icon: iconFile, // Window icon - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : undefined, // macOS-style title bar + // macOS: hidden inset title bar with traffic lights. Windows: hidden title bar with + // native window controls overlay (WCO) — the OS caption buttons drawn over the AppBar. + // Linux: frameless with the custom in-app window buttons. + titleBarStyle: + process.platform === "darwin" ? "hiddenInset" : process.platform === "win32" ? "hidden" : undefined, + titleBarOverlay: getTitleBarOverlayOptions(), // Native Windows caption buttons (WCO) transparent: process.platform === "darwin", // Transparent title bar on macOS vibrancy: process.platform === "darwin" ? "under-window" : undefined, // macOS vibrancy effect visualEffectState: process.platform === "darwin" ? "followWindow" : undefined, backgroundMaterial: process.platform === "win32" ? "mica" : undefined, // Windows 11 material effect backgroundColor: "#00ffffff", // Transparent background color maximizable: true, // Allow maximizing - frame: process.platform === "darwin", // Frameless on macOS + frame: process.platform !== "linux", // Frameless only on Linux (WCO needs the OS frame on Windows) hasShadow: true, // macOS shadow trafficLightPosition: process.platform === "darwin" ? { x: 12, y: 10 } : undefined, // macOS traffic light position webPreferences: { @@ -1006,6 +1048,30 @@ export class WindowPresenter implements IWindowPresenter { return Array.from(this.windows.values()).filter((window) => !window.isDestroyed()); } + /** + * Re-apply the Window Controls Overlay after a light/dark theme change so the native + * caption button symbols keep matching the sidebar surface. (Windows only.) + */ + syncWindowTitleBarAppearance(): void { + if (process.platform !== "win32") { + return; + } + const overlay = getTitleBarOverlayOptions(); + if (!overlay) { + return; + } + for (const window of this.windows.values()) { + if (window.isDestroyed()) { + continue; + } + try { + window.setTitleBarOverlay(overlay); + } catch (error) { + console.error("Failed to re-apply window controls overlay:", error); + } + } + } + /** * Get the active tab ID of the given window. * @param windowId Window ID. 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/backend-core/src/scheduled/normalize.ts b/packages/backend-core/src/scheduled/normalize.ts index 785d657d1..9e547c48e 100644 --- a/packages/backend-core/src/scheduled/normalize.ts +++ b/packages/backend-core/src/scheduled/normalize.ts @@ -167,20 +167,22 @@ export const computeNextFireAt = (task: ScheduledTask, after: number): number | 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); + const effectiveAfter = task.lastFiredAt != null ? Math.max(after, task.lastFiredAt) : after; + let candidate = buildWallClockToday(effectiveAfter, trigger.hour, trigger.minute, 0); + if (candidate <= effectiveAfter) { + candidate = buildWallClockToday(effectiveAfter, trigger.hour, trigger.minute, 1); } return candidate; } case "weekly": { - const reference = new Date(after); + const effectiveAfter = task.lastFiredAt != null ? Math.max(after, task.lastFiredAt) : after; + const reference = new Date(effectiveAfter); const currentDay = reference.getDay(); let dayOffset = (trigger.dayOfWeek - currentDay + 7) % 7; - let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset); - if (candidate <= after) { + let candidate = buildWallClockToday(effectiveAfter, trigger.hour, trigger.minute, dayOffset); + if (candidate <= effectiveAfter) { dayOffset += 7; - candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset); + candidate = buildWallClockToday(effectiveAfter, trigger.hour, trigger.minute, dayOffset); } return candidate; } diff --git a/packages/shared/src/types/presenters/legacy.presenters.d.ts b/packages/shared/src/types/presenters/legacy.presenters.d.ts index c044b4652..c6db28126 100644 --- a/packages/shared/src/types/presenters/legacy.presenters.d.ts +++ b/packages/shared/src/types/presenters/legacy.presenters.d.ts @@ -297,6 +297,7 @@ export interface IWindowPresenter { sendToWebContents(webContentsId: number, channel: string, ...args: unknown[]): Promise; sendToActiveTab(windowId: number, channel: string, ...args: unknown[]): Promise; getAllWindows(): BrowserWindow[]; + syncWindowTitleBarAppearance(): void; toggleFloatingChatWindow(floatingButtonPosition?: { x: number; y: number; diff --git a/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx b/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx index c4a08ee3f..26417be74 100644 --- a/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx +++ b/packages/ui/settings/components/AgentExtensionPolicyPanel.tsx @@ -1,7 +1,7 @@ -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 { usePresenter } from "#api/presenterBridge"; +import { createMcpClient } from "#api/McpClient"; type AgentExtensionPolicyValue = { enabledMcpServerIds?: string[]; @@ -26,14 +26,6 @@ const normalizeSelection = (value?: string[]): string[] => { return Array.from(new Set(value.map((item) => item.trim()).filter(Boolean))); }; -const updateSelection = (current: string[] | undefined, itemId: string, checked: boolean): string[] => { - const currentSelection = normalizeSelection(current); - if (checked) { - return Array.from(new Set([...currentSelection, itemId])); - } - return currentSelection.filter((id) => id !== itemId); -}; - function PolicyScopeList({ title, description, @@ -51,8 +43,10 @@ function PolicyScopeList({ onClear: () => void; disabled?: boolean; }) { - const selectedSet = useMemo(() => new Set(normalizeSelection(selectedIds)), [selectedIds]); - const selectedCount = selectedSet.size; + const selectedSet = new Set( + selectedIds === undefined ? items.map((item) => item.id) : normalizeSelection(selectedIds), + ); + const selectedCount = selectedIds === undefined ? items.length : selectedSet.size; const scopeLabel = selectedIds === undefined ? "All allowed" : selectedCount === 0 ? "None allowed" : `${selectedCount} selected`; @@ -103,7 +97,7 @@ export default function AgentExtensionPolicyPanel({ onChange, disabled = false, }: AgentExtensionPolicyPanelProps) { - const configPresenter = usePresenter("configPresenter"); + const mcpClient = createMcpClient(); const [loading, setLoading] = useState(true); const [mcpServers, setMcpServers] = useState< Array<{ id: string; label: string; pluginId?: string; source?: string; sourceId?: string }> @@ -111,7 +105,7 @@ export default function AgentExtensionPolicyPanel({ useEffect(() => { let mounted = true; - void configPresenter + void mcpClient .getMcpServers() .then((servers) => { if (!mounted) { @@ -137,7 +131,7 @@ export default function AgentExtensionPolicyPanel({ return () => { mounted = false; }; - }, [configPresenter]); + }, [mcpClient]); const normalizedValue = { enabledMcpServerIds: Array.isArray(value.enabledMcpServerIds) @@ -152,12 +146,31 @@ export default function AgentExtensionPolicyPanel({ }); }; + const handleToggle = (itemId: string, checked: boolean) => { + const allIds = mcpServers.map((item) => item.id); + const current = normalizedValue.enabledMcpServerIds; + if (checked) { + const explicit = normalizeSelection(current); + const next = Array.from(new Set([...explicit, itemId])); + const nextSet = new Set(next); + if (allIds.every((id) => nextSet.has(id))) { + updateValue({ ...normalizedValue, enabledMcpServerIds: undefined }); + return; + } + updateValue({ ...normalizedValue, enabledMcpServerIds: next }); + return; + } + const base = current === undefined ? allIds : current; + updateValue({ ...normalizedValue, enabledMcpServerIds: base.filter((id) => id !== itemId) }); + }; + return (
MCP scope

- Leave this unset to allow every configured MCP server. An empty list blocks MCP tools entirely. + Checked servers are available to this agent. Uncheck when everything is allowed to create an explicit + allowlist.

@@ -173,12 +186,7 @@ export default function AgentExtensionPolicyPanel({ description="Limit which MCP servers this agent can use." items={mcpServers} selectedIds={normalizedValue.enabledMcpServerIds} - onToggle={(itemId, checked) => { - updateValue({ - ...normalizedValue, - enabledMcpServerIds: updateSelection(normalizedValue.enabledMcpServerIds, itemId, checked), - }); - }} + onToggle={handleToggle} onClear={() => updateValue({ ...normalizedValue, enabledMcpServerIds: undefined })} disabled={disabled} /> diff --git a/packages/ui/settings/components/ArgosAgentsSettings.tsx b/packages/ui/settings/components/ArgosAgentsSettings.tsx index 7675e4549..242a01933 100644 --- a/packages/ui/settings/components/ArgosAgentsSettings.tsx +++ b/packages/ui/settings/components/ArgosAgentsSettings.tsx @@ -147,6 +147,8 @@ const GROUP_ORDER = [ "agent-image-generation", "agent-skills", "argos-settings", + "argos-orchestration", + "pi", "yobrowser", ] as const; @@ -238,6 +240,7 @@ export default function ArgosAgentsSettings() { const [form, setForm] = useState({ ...EMPTY_FORM }); const [openModelPicker, setOpenModelPicker] = useState>({}); const [tools, setTools] = useState([]); + const [expandedToolGroups, setExpandedToolGroups] = useState>(new Set()); const [systemPromptDialogOpen, setSystemPromptDialogOpen] = useState(false); const [memoryDialogOpen, setMemoryDialogOpen] = useState(false); const [loadingSystemPrompts, setLoadingSystemPrompts] = useState(false); @@ -557,7 +560,7 @@ export default function ArgosAgentsSettings() { () => [ { value: CURRENT_SUBAGENT_TARGET, - label: "settings.argosAgents.subagentTargetSelf", + label: "Self", }, ...availableSubagentTargetAgents.map((agent) => ({ value: agent.id, label: agent.name })), ], @@ -576,6 +579,10 @@ export default function ArgosAgentsSettings() { return "Skills"; case "argos-settings": return "Settings"; + case "argos-orchestration": + return "Orchestration"; + case "pi": + return "Pi"; case "yobrowser": return "Browser"; default: @@ -1005,13 +1012,12 @@ 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" > - settings.argosAgents.unnamed
{newAgentName.trim() || "Unnamed Agent"}
setNewAgentName(e.target.value)} - placeholder="settings.argosAgents.namePlaceholder" + placeholder="Enter agent name" autoFocus />
@@ -1075,7 +1081,7 @@ export default function ArgosAgentsSettings() { updateForm("name", e.target.value)} - placeholder="settings.argosAgents.namePlaceholder" + placeholder="Enter agent name" />
@@ -1205,19 +1211,11 @@ export default function ArgosAgentsSettings() { {modelFieldConfigs.map((field) => (
- {field.key === "visionModel" ? ( - <> - settings.argosAgents.visionModel - Vision model - - ) : field.key === "imageGenerationModel" ? ( - <> - settings.argosAgents.imageGenerationModel - Image generation model - - ) : ( - field.label - )} + {field.key === "visionModel" + ? "Vision model" + : field.key === "imageGenerationModel" + ? "Image generation model" + : field.label}
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. This will be prepended to every user message." />
@@ -1589,46 +1587,105 @@ export default function ArgosAgentsSettings() { -
-
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 +1766,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..b6b93af42 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"; @@ -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([]); @@ -62,6 +65,15 @@ export default function ScheduledTasksSettings() { const [recurringTimeValues, setRecurringTimeValues] = useState([]); const tasks = useMemo(() => settings?.tasks ?? [], [settings]); + const settingsRef = useRef(settings); + // 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]); const enabledAgents = useMemo(() => agents.filter((a) => a.enabled), [agents]); const getModelLabel = useCallback( @@ -96,27 +108,61 @@ export default function ScheduledTasksSettings() { ); }, [tasks]); - const loadSettings = useCallback(async () => { + // 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(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", + }); + setPendingMutations((n) => Math.max(0, n - 1)); + throw error; + }, + ); + mutationQueueRef.current = run.catch(() => {}); + return run; + }, + [toast], + ); + + const loadSettings = useCallback(() => { setIsLoading(true); - try { - const [nextSettings, nextAgents] = await Promise.all([client.list(), configClient.listAgents()]); - setSettings(nextSettings); - setAgents(nextAgents); - } catch (error) { - toast({ - title: "Operation failed", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsLoading(false); - } + 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( - async (task: ScheduledTask) => { - setIsSaving(true); - try { + (task: ScheduledTask) => + runMutation(`persist task ${task.id}`, async () => { const response = await client.upsert({ id: task.id, name: task.name, @@ -125,32 +171,22 @@ export default function ScheduledTasksSettings() { action: structuredClone(task.action), }); setSettings(response.settings); - } catch (error) { - toast({ - title: "Operation failed", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsSaving(false); - } - }, - [client, toast], + }), + [client, runMutation], ); const commitTask = useCallback( - async (index: number) => { - const task = tasks[index]; - if (!task) return; - await persistTask(task); + (index: number, override?: ScheduledTask) => { + const task = override ?? settingsRef.current?.tasks[index]; + if (!task) return Promise.resolve(); + return persistTask(task); }, - [tasks, persistTask], + [persistTask], ); useEffect(() => { void loadSettings(); }, [loadSettings]); - useEffect(() => { refreshFormBuffers(); }, [tasks, refreshFormBuffers]); @@ -164,15 +200,14 @@ export default function ScheduledTasksSettings() { actions={ settings && !isLoading ? ( <> - {isSaving && ( + {pendingMutations > 0 && ( Saving )} )} - {(!isMacOS || isBrowser) && ( + {(showCustomWindowButtons || isBrowser) && ( - -
- - - {titleSegments.map((segment, index) => - segment.match ? ( - - {segment.text} - - ) : ( - {segment.text} - ), - )} - - {isWorking && ( - - )} - + + onSelect(session)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + 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/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 && (