From cb71afa6b7cff4f9751428b50ad45aa64112faba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 9 Jul 2026 00:09:02 -0400 Subject: [PATCH] Avatar photo uploads and a global handbrake for the agent engine Two operator asks, one review gate: Avatar portraits (migration 052): the avatar editor gains "Upload photo" - the renderer downscales the chosen picture to a ~96px cover crop and stores it as a small data URL (PNG keeps transparency, the rest compresses to JPEG), so the snapshot stays light and the CSP needs nothing new. The portrait wins over emoji and initials everywhere the shared Avatar renders (chat, org chart, office card, sidebar), and "Remove photo" falls back cleanly. The main process validates scheme and size (base64 image data URL, <=300k chars) so a rogue caller cannot re-bloat the snapshot. e2e uploads a real PNG through the profile dialog and sees the portrait land in the sidebar. Global pause (migration 053): when agents start talking in circles, one topbar toggle - visible on every page - pulls the handbrake. app_state.autorun_paused survives restarts; the queue worker checks it each tick and neither enqueues nor claims while set (the run already in flight finishes). Queued work simply waits and the paused state renders as an amber "resume" pill. A unit test drives the real worker: paused leaves a queued run untouched through many ticks, resuming claims it within moments; e2e flips the toggle in the live shell. --- src/main/app/ipc-handlers.ts | 1 + src/main/app/task-queue-worker.ts | 16 ++++ .../database/migrations/052-avatar-images.ts | 7 ++ .../migrations/053-autonomous-pause.ts | 6 ++ src/main/database/schema.ts | 4 + src/main/services/organization-service.ts | 51 +++++++++-- src/main/services/snapshot-service.ts | 7 ++ src/preload/index.ts | 1 + src/renderer/src/app/App.tsx | 8 +- src/renderer/src/i18n/zh.ts | 8 ++ src/renderer/src/pages/TeamPage.tsx | 3 +- src/renderer/src/shared/AvatarField.tsx | 71 ++++++++++++++-- src/renderer/src/shared/Shell.tsx | 15 +++- src/renderer/src/shared/ui.tsx | 15 +++- src/renderer/src/styles/components.css | 24 ++++++ src/shared/commands/organization.ts | 7 ++ src/shared/desktop-api.ts | 2 + src/shared/domain.ts | 4 + src/shared/ipc.ts | 1 + src/shared/snapshot-domain.ts | 2 + tests/e2e/desktop.spec.ts | 24 ++++++ tests/unit/autonomous-pause.test.ts | 84 +++++++++++++++++++ tests/unit/domain-services.test.ts | 2 +- tests/unit/file-helpers.test.ts | 2 +- tests/unit/profile-identity.test.ts | 31 +++++++ 25 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 src/main/database/migrations/052-avatar-images.ts create mode 100644 src/main/database/migrations/053-autonomous-pause.ts create mode 100644 tests/unit/autonomous-pause.test.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 6a9f1fcd..f317aa1e 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -136,6 +136,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); ipcMain.handle(IPC_CHANNELS.updateEmployeeProfile, (_event, input) => mutate(() => organization.updateEmployeeProfile(input))); + ipcMain.handle(IPC_CHANNELS.setAutonomousPaused, (_event, input) => mutate(() => organization.setAutonomousPaused(Boolean(input.paused)))); ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input))); ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input))); diff --git a/src/main/app/task-queue-worker.ts b/src/main/app/task-queue-worker.ts index f15d0a6c..42f9dc55 100644 --- a/src/main/app/task-queue-worker.ts +++ b/src/main/app/task-queue-worker.ts @@ -16,6 +16,13 @@ export interface QueuedRun { org_id: string; } +/** True while the operator has pulled the global autonomous handbrake. */ +export function isAutonomousPaused(connection: DatabaseConnection): boolean { + const row = connection.db.prepare("select autorun_paused from app_state where id = 1") + .get() as { autorun_paused: number } | undefined; + return row?.autorun_paused === 1; +} + /** * Atomically claims the oldest queued task_run by transitioning it * `queued` -> `running` in one transaction; returns the claimed run or null. @@ -48,11 +55,20 @@ export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void { const taskExecution = new TaskExecutionService(input.connection.db, undefined, undefined, notifyDataChanged); const intervalMs = input.intervalMs ?? DEFAULT_INTERVAL_MS; let running = false; + let wasPaused = false; const tick = async (): Promise => { if (running) return; running = true; try { + // The operator's global pause parks the whole engine: nothing new is + // enqueued or claimed until they resume. Queued work simply waits. + const paused = isAutonomousPaused(input.connection); + if (paused !== wasPaused) { + console.info(paused ? "[task-queue] paused by the operator; no new runs start." : "[task-queue] resumed by the operator."); + wasPaused = paused; + } + if (paused) return; const enqueued = input.connection.transaction(() => taskExecution.enqueueHeartbeatRuns()); const claimed = claimNextQueuedRun(input.connection); if (!claimed) { diff --git a/src/main/database/migrations/052-avatar-images.ts b/src/main/database/migrations/052-avatar-images.ts new file mode 100644 index 00000000..4d1df319 --- /dev/null +++ b/src/main/database/migrations/052-avatar-images.ts @@ -0,0 +1,7 @@ +export const avatarImagesSchema = ` + alter table agents add column avatar_image text; + alter table employees add column avatar_image text; + + insert into schema_migrations(version, name, applied_at) + values (52, 'avatar_images', datetime('now')); +`; diff --git a/src/main/database/migrations/053-autonomous-pause.ts b/src/main/database/migrations/053-autonomous-pause.ts new file mode 100644 index 00000000..be1f8514 --- /dev/null +++ b/src/main/database/migrations/053-autonomous-pause.ts @@ -0,0 +1,6 @@ +export const autonomousPauseSchema = ` + alter table app_state add column autorun_paused integer not null default 0; + + insert into schema_migrations(version, name, applied_at) + values (53, 'autonomous_pause', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 39c56542..b1b175a0 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -50,6 +50,8 @@ import { uiPreferencesSchema } from "./migrations/048-ui-preferences"; import { avatarsSchema } from "./migrations/049-avatars"; import { financeSalesDepartmentsSchema } from "./migrations/050-finance-sales-departments"; import { purgeRuntimeSnapshotBloatSchema } from "./migrations/051-purge-runtime-snapshot-bloat"; +import { avatarImagesSchema } from "./migrations/052-avatar-images"; +import { autonomousPauseSchema } from "./migrations/053-autonomous-pause"; interface Migration { version: number; @@ -109,6 +111,8 @@ const MIGRATIONS: Migration[] = [ { version: 49, name: "avatars", sql: avatarsSchema }, { version: 50, name: "finance_sales_departments", sql: financeSalesDepartmentsSchema }, { version: 51, name: "purge_runtime_snapshot_bloat", sql: purgeRuntimeSnapshotBloatSchema }, + { version: 52, name: "avatar_images", sql: avatarImagesSchema }, + { version: 53, name: "autonomous_pause", sql: autonomousPauseSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index ad353c94..a62356ac 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -47,6 +47,20 @@ export class OrganizationService { }); } + /** + * The global handbrake: while paused, the autonomous queue stops enqueuing + * and claiming runs (the run already in flight finishes). Survives + * restarts, so a runaway conversation stays stopped until the operator + * resumes it. + */ + setAutonomousPaused(paused: boolean): void { + this.db.prepare(` + insert into app_state(id, active_org_id, autorun_paused, updated_at) + values (1, null, ?, ?) + on conflict(id) do update set autorun_paused = excluded.autorun_paused, updated_at = excluded.updated_at + `).run(paused ? 1 : 0, new Date().toISOString()); + } + updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void { const current = this.db.prepare("select theme, language from app_state where id = 1") .get() as { theme: string; language: string } | undefined; @@ -148,8 +162,8 @@ export class OrganizationService { } updateAgent(input: UpdateAgentInput): void { - const agent = this.db.prepare("select id, name, avatar_emoji, avatar_color from agents where id = ? and org_id = ?") - .get(input.agentId, input.orgId) as { id: string; name: string; avatar_emoji: string | null; avatar_color: string | null } | undefined; + const agent = this.db.prepare("select id, name, avatar_emoji, avatar_color, avatar_image from agents where id = ? and org_id = ?") + .get(input.agentId, input.orgId) as { id: string; name: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined; if (!agent) throw new Error("Agent was not found."); const role = input.role.trim(); if (!role) throw new Error("Agent role is required."); @@ -168,12 +182,13 @@ export class OrganizationService { const budgetCents = Math.max(0, Math.round(input.budgetCents)); const avatarEmoji = input.avatarEmoji === undefined ? agent.avatar_emoji : (input.avatarEmoji.trim() || null); const avatarColor = input.avatarColor === undefined ? agent.avatar_color : normalizeAvatarColor(input.avatarColor); + const avatarImage = input.avatarImage === undefined ? agent.avatar_image : normalizeAvatarImage(input.avatarImage); const result = this.db.prepare(` update agents set name = ?, role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?, - avatar_emoji = ?, avatar_color = ? + avatar_emoji = ?, avatar_color = ?, avatar_image = ? where id = ? and org_id = ? - `).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, input.agentId, input.orgId); + `).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, avatarImage, input.agentId, input.orgId); if (result.changes === 0) throw new Error("Agent update failed."); this.audit.record({ orgId: input.orgId, @@ -187,15 +202,16 @@ export class OrganizationService { } updateEmployeeProfile(input: UpdateEmployeeProfileInput): void { - const employee = this.db.prepare("select id, avatar_emoji, avatar_color from employees where id = ? and org_id = ?") - .get(input.employeeId, input.orgId) as { id: string; avatar_emoji: string | null; avatar_color: string | null } | undefined; + const employee = this.db.prepare("select id, avatar_emoji, avatar_color, avatar_image from employees where id = ? and org_id = ?") + .get(input.employeeId, input.orgId) as { id: string; avatar_emoji: string | null; avatar_color: string | null; avatar_image: string | null } | undefined; if (!employee) throw new Error("Employee was not found."); const displayName = input.displayName.trim(); if (!displayName) throw new Error("Display name is required."); const avatarEmoji = input.avatarEmoji === undefined ? employee.avatar_emoji : (input.avatarEmoji.trim() || null); const avatarColor = input.avatarColor === undefined ? employee.avatar_color : normalizeAvatarColor(input.avatarColor); - this.db.prepare("update employees set display_name = ?, avatar_emoji = ?, avatar_color = ? where id = ? and org_id = ?") - .run(displayName, avatarEmoji, avatarColor, input.employeeId, input.orgId); + const avatarImage = input.avatarImage === undefined ? employee.avatar_image : normalizeAvatarImage(input.avatarImage); + this.db.prepare("update employees set display_name = ?, avatar_emoji = ?, avatar_color = ?, avatar_image = ? where id = ? and org_id = ?") + .run(displayName, avatarEmoji, avatarColor, avatarImage, input.employeeId, input.orgId); this.audit.record({ orgId: input.orgId, entityType: "employee", @@ -531,3 +547,22 @@ function normalizeAvatarColor(value: string): string | null { if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error("Avatar color must be a #rrggbb hex value."); return color.toLowerCase(); } + +/** + * Avatar portraits persist as small base64 data URLs (the renderer downscales + * before submitting). The cap keeps a rogue caller from turning the snapshot + * back into a megabyte payload. + */ +const MAX_AVATAR_IMAGE_CHARS = 300_000; + +function normalizeAvatarImage(value: string): string | null { + const image = value.trim(); + if (!image) return null; + if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) { + throw new Error("Avatar image must be a base64 data URL (png, jpeg, or webp)."); + } + if (image.length > MAX_AVATAR_IMAGE_CHARS) { + throw new Error("Avatar image is too large - upload a smaller picture."); + } + return image; +} diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 774cb6b8..45d8bed2 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -86,6 +86,7 @@ export class SnapshotService { return { activeOrgId, uiPreferences: this.resolveUiPreferences(), + autonomousPaused: this.resolveAutonomousPaused(), organizations, employees: list(this.db, "employees", "created_at asc"), agents, @@ -182,6 +183,12 @@ export class SnapshotService { return known[0] ?? null; } + private resolveAutonomousPaused(): boolean { + const row = this.db.prepare("select autorun_paused from app_state where id = 1") + .get() as { autorun_paused: number } | undefined; + return row?.autorun_paused === 1; + } + private resolveUiPreferences(): AppSnapshot["uiPreferences"] { const row = this.db.prepare("select theme, language from app_state where id = 1") .get() as { theme: string; language: string } | undefined; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0494fe88..103663b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,7 @@ const api: DesktopApi = { createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), updateEmployeeProfile: (input) => invoke(IPC_CHANNELS.updateEmployeeProfile, input), + setAutonomousPaused: (input) => invoke(IPC_CHANNELS.setAutonomousPaused, input), createTask: (input) => invoke(IPC_CHANNELS.createTask, input), updateTask: (input) => invoke(IPC_CHANNELS.updateTask, input), deleteTask: (input) => invoke(IPC_CHANNELS.deleteTask, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index d2c00674..0e3876c5 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -25,11 +25,15 @@ export function App() { const orgId = snapshot?.activeOrgId ?? null; for (const agent of snapshot?.agents ?? []) { if (agent.orgId !== orgId) continue; - if (agent.avatarEmoji || agent.avatarColor) directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor }); + if (agent.avatarEmoji || agent.avatarColor || agent.avatarImage) { + directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor, image: agent.avatarImage }); + } } for (const employee of snapshot?.employees ?? []) { if (employee.orgId !== orgId) continue; - if (employee.avatarEmoji || employee.avatarColor) directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor }); + if (employee.avatarEmoji || employee.avatarColor || employee.avatarImage) { + directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor, image: employee.avatarImage }); + } } return directory; }, [snapshot?.agents, snapshot?.employees, snapshot?.activeOrgId]); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index ae373ba9..f9d62a08 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -126,6 +126,14 @@ export const zh: Record = { "Edit profile": "编辑个人资料", "Former member": "已离职成员", "Jump to latest": "跳到最新消息", + "Upload photo": "上传照片", + "Pause agents": "暂停智能体", + "Agents paused": "已暂停 · 点击恢复", + "Pause all agents: the run already in flight finishes, nothing new starts.": "暂停全部智能体:进行中的这一条会跑完,之后不再派发新任务。", + "Agents are paused: no new runs start until you resume. Click to resume.": "智能体已暂停:恢复前不会开始任何新任务。点击恢复。", + "Remove photo": "移除照片", + "Avatar image file": "头像图片文件", + "That file could not be read as an image.": "无法将该文件读取为图片。", "Conversation messages": "会话消息", "Whip": "鞭子", "Office tools": "办公室工具", diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index f45bc1ea..cc4c411c 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -93,6 +93,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { modelId: optional(form, "modelId"), avatarEmoji: String(form.get("avatarEmoji") ?? ""), avatarColor: String(form.get("avatarColor") ?? ""), + avatarImage: String(form.get("avatarImage") ?? ""), })).then((result) => { if (result) setEditAgentId(null); }); }; @@ -172,7 +173,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { {editingAgent && ( setEditAgentId(null)} onSubmit={submitEdit}> - + diff --git a/src/renderer/src/shared/AvatarField.tsx b/src/renderer/src/shared/AvatarField.tsx index c94c9a27..447f6697 100644 --- a/src/renderer/src/shared/AvatarField.tsx +++ b/src/renderer/src/shared/AvatarField.tsx @@ -1,28 +1,58 @@ -import { useState } from "react"; -import { Avatar, AVATAR_COLORS, Field } from "./ui"; +import { useRef, useState, type ChangeEvent } from "react"; +import { Avatar, AVATAR_COLORS, Button, Field } from "./ui"; import { t } from "../i18n/i18n"; +/** Longest edge of a stored portrait; keeps data URLs at a few kilobytes. */ +const PORTRAIT_SIZE = 96; + /** - * Form-embedded avatar editor: an emoji (or empty for initials) plus a - * background swatch. Submits through the surrounding form as `avatarEmoji` - * and `avatarColor`; the "auto" swatch clears the color back to the - * name-hashed palette. + * Form-embedded avatar editor: an uploaded portrait (downscaled to a small + * data URL), or an emoji (or empty for initials) on a background swatch. + * Submits through the surrounding form as `avatarImage`, `avatarEmoji`, and + * `avatarColor`; the "auto" swatch clears the color back to the name-hashed + * palette and "Remove photo" falls back to emoji/initials. */ export function AvatarField({ previewName, defaultEmoji, defaultColor, + defaultImage, }: { previewName: string; defaultEmoji: string | null; defaultColor: string | null; + defaultImage?: string | null; }) { const [emoji, setEmoji] = useState(defaultEmoji ?? ""); const [color, setColor] = useState(defaultColor ?? ""); + const [image, setImage] = useState(defaultImage ?? ""); + const [uploadError, setUploadError] = useState(null); + const fileInputRef = useRef(null); + + const onFileChosen = (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0] ?? null; + event.currentTarget.value = ""; + if (!file) return; + setUploadError(null); + void portraitDataUrl(file) + .then(setImage) + .catch((error) => setUploadError(error instanceof Error ? error.message : String(error))); + }; + return (
- + + + + {image && } + + {uploadError &&
{uploadError}
}
); } + +/** + * Downscales the chosen picture to a small square cover crop and returns a + * compact data URL. PNG keeps transparency; everything else compresses to + * JPEG. + */ +async function portraitDataUrl(file: File): Promise { + const bitmap = await createImageBitmap(file).catch(() => { + throw new Error(t("That file could not be read as an image.")); + }); + try { + const canvas = document.createElement("canvas"); + canvas.width = PORTRAIT_SIZE; + canvas.height = PORTRAIT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error(t("That file could not be read as an image.")); + const scale = Math.max(PORTRAIT_SIZE / bitmap.width, PORTRAIT_SIZE / bitmap.height); + const drawW = bitmap.width * scale; + const drawH = bitmap.height * scale; + ctx.drawImage(bitmap, (PORTRAIT_SIZE - drawW) / 2, (PORTRAIT_SIZE - drawH) / 2, drawW, drawH); + return file.type === "image/png" ? canvas.toDataURL("image/png") : canvas.toDataURL("image/jpeg", 0.85); + } finally { + bitmap.close(); + } +} diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx index 4b87b624..6d012cc1 100644 --- a/src/renderer/src/shared/Shell.tsx +++ b/src/renderer/src/shared/Shell.tsx @@ -135,6 +135,18 @@ export function Shell({ {activeLabel}
+ {!isMac && ( void window.agentCompany.windowMinimize()} /> @@ -243,6 +255,7 @@ function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClos displayName: String(form.get("displayName") ?? "").trim(), avatarEmoji: String(form.get("avatarEmoji") ?? ""), avatarColor: String(form.get("avatarColor") ?? ""), + avatarImage: String(form.get("avatarImage") ?? ""), }).then((result) => { if (result.ok) onClose(); else setError(result.error.message); @@ -251,7 +264,7 @@ function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClos return ( - + {error &&
{error}
}
); diff --git a/src/renderer/src/shared/ui.tsx b/src/renderer/src/shared/ui.tsx index 627274f0..71d2f49f 100644 --- a/src/renderer/src/shared/ui.tsx +++ b/src/renderer/src/shared/ui.tsx @@ -4,6 +4,8 @@ import { Icon, type IconName } from "./Icon"; export interface AvatarLook { emoji: string | null; color: string | null; + /** data-URL portrait; wins over emoji and initials when set. */ + image?: string | null; } /** @@ -66,9 +68,18 @@ export function Avatar({ const fg = custom?.color ? "#fff" : hashedFg; const dim = size === "sm" ? 14 : size === "lg" ? 18 : size === "xl" ? 24 : 14; const emoji = custom?.emoji ?? null; + const image = custom?.image ?? null; return ( -
- {emoji ? {emoji} : bot ? : initials(name)} +
+ {image ? ( + + ) : emoji ? ( + {emoji} + ) : bot ? ( + + ) : ( + initials(name) + )}
); } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index e1478ca5..0ee79891 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -303,3 +303,27 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18); } .jump-latest:hover { background: var(--hover); } +.avatar-image { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } + +/* Global autonomous-engine pause toggle in the topbar */ +.autopause-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: none; + color: var(--text-2); + font: inherit; + font-size: 12px; + font-weight: 540; + cursor: pointer; + -webkit-app-region: no-drag; +} +.autopause-toggle:hover { background: var(--hover); color: var(--text-1); } +.autopause-toggle.paused { + background: var(--amber, #f59e0b); + border-color: transparent; + color: #231600; +} diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 884fcdb4..d7d8d1a8 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -40,6 +40,12 @@ export interface UpdateAgentInput { avatarEmoji?: string; /** Background color for the avatar tile; empty string clears it. */ avatarColor?: string; + /** data:image/... portrait (already downscaled); empty string clears it. */ + avatarImage?: string; +} + +export interface SetAutonomousPausedInput { + paused: boolean; } export interface UpdateEmployeeProfileInput { @@ -48,6 +54,7 @@ export interface UpdateEmployeeProfileInput { displayName: string; avatarEmoji?: string; avatarColor?: string; + avatarImage?: string; } export interface CreateAgentInput { diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 9bb77f97..3a55973f 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -6,6 +6,7 @@ import type { BootstrapOnboardingSummary, CancelProviderResponseInput, CreateAgentInput, + SetAutonomousPausedInput, UpdateAgentInput, UpdateEmployeeProfileInput, CreateApprovalInput, @@ -109,6 +110,7 @@ export interface DesktopApi { createAgent(input: CreateAgentInput): Promise>; updateAgent(input: UpdateAgentInput): Promise>; updateEmployeeProfile(input: UpdateEmployeeProfileInput): Promise>; + setAutonomousPaused(input: SetAutonomousPausedInput): Promise>; createTask(input: CreateTaskInput): Promise>; updateTask(input: UpdateTaskInput): Promise>; deleteTask(input: DeleteTaskInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 4c7acb09..9f5b3df0 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -51,6 +51,8 @@ export interface EmployeeRecord { role: string; avatarEmoji: string | null; avatarColor: string | null; + /** Small data-URL portrait; wins over emoji/initials when set. */ + avatarImage: string | null; createdAt: string; } @@ -73,6 +75,8 @@ export interface AgentRecord { lifecycle: string; avatarEmoji: string | null; avatarColor: string | null; + /** Small data-URL portrait; wins over emoji/initials when set. */ + avatarImage: string | null; createdAt: string; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 0bb1bedf..2fc0b737 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,7 @@ export const IPC_CHANNELS = { createAgent: "agentcompany:create-agent", updateAgent: "agentcompany:update-agent", updateEmployeeProfile: "agentcompany:update-employee-profile", + setAutonomousPaused: "agentcompany:set-autonomous-paused", createTask: "agentcompany:create-task", updateTask: "agentcompany:update-task", deleteTask: "agentcompany:delete-task", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 2579866c..cb220a94 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -69,6 +69,8 @@ export interface UiPreferences { export interface AppSnapshot { activeOrgId: string | null; uiPreferences: UiPreferences; + /** True while the operator has paused the autonomous run engine. */ + autonomousPaused: boolean; organizations: OrganizationRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index a19ce1df..5482b48f 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -101,6 +101,20 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.locator(".modal").getByRole("button", { name: "Save" }).click(); await expect(page.getByRole("button", { name: "Edit profile" })).toContainText("Dana Zhang"); await expect(page.getByRole("button", { name: "Edit profile" }).locator(".avatar")).toContainText("🎯"); + + // Uploading a photo replaces the emoji tile with the portrait everywhere. + await page.getByRole("button", { name: "Edit profile" }).click(); + await page.getByLabel("Avatar image file").setInputFiles({ + name: "portrait.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), + }); + await expect(page.locator(".modal .avatar img")).toBeVisible(); + await page.locator(".modal").getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("button", { name: "Edit profile" }).locator(".avatar img")).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); await page.getByRole("button", { name: "New task" }).click(); await page.getByLabel("Title").fill("Production deploy"); @@ -170,6 +184,16 @@ test("onboards, hires, and creates work in the Electron shell", async () => { }); expect(skillSlugs).toEqual(["engineering-backend-architect", "humanizer"]); + // The global pause toggle lives in the topbar on every page. + const pauseToggle = page.getByRole("button", { name: "Pause agents" }); + await expect(pauseToggle).toBeVisible(); + await pauseToggle.click(); + const pausedToggle = page.getByRole("button", { name: "Agents paused" }); + await expect(pausedToggle).toBeVisible(); + await expect(pausedToggle).toHaveAttribute("aria-pressed", "true"); + await pausedToggle.click(); + await expect(page.getByRole("button", { name: "Pause agents" })).toHaveAttribute("aria-pressed", "false"); + // Virtual office page await page.getByRole("button", { name: "Office" }).click(); await expect(page.locator("canvas")).toBeVisible(); diff --git a/tests/unit/autonomous-pause.test.ts b/tests/unit/autonomous-pause.test.ts new file mode 100644 index 00000000..fc382749 --- /dev/null +++ b/tests/unit/autonomous-pause.test.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import type { BrowserWindow } from "electron"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { isAutonomousPaused, startTaskQueueWorker } from "../../src/main/app/task-queue-worker"; + +const fakeWindow = { + isDestroyed: () => false, + webContents: { send: () => undefined, isDestroyed: () => false }, +} as unknown as BrowserWindow; + +function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + const poll = () => { + if (predicate()) return resolve(true); + if (Date.now() - startedAt > timeoutMs) return resolve(false); + setTimeout(poll, 20); + }; + poll(); + }); +} + +describe("global autonomous pause", () => { + test("the handbrake parks the queue worker and resuming releases it", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-pause-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let orgId = ""; + let runId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? limit 1").get(orgId) as { id: string }).id; + const agentId = organization.createAgent({ + orgId, name: "Ada", role: "Engineer", department: "Engineering", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, + }); + const taskId = organization.createTask({ + orgId, title: "Queued work", description: "Waits for the engine.", status: "backlog", + assigneeAgentId: agentId, risk: "low", projectId: null, goalId: null, workspaceId: null, + priority: "normal", requiresFilesystem: false, + }); + const serviceIdentityId = (connection.db.prepare("select id from service_identities where org_id = ? and agent_id = ?") + .get(orgId, agentId) as { id: string }).id; + runId = `run_pause_${randomUUID().slice(0, 8)}`; + connection.db.prepare(` + insert into task_runs( + id, org_id, task_id, agent_id, provider_id, provider_request_id, + execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json, + trigger, state, created_at, started_at, completed_at, error_message + ) values (?, ?, ?, ?, ?, null, ?, ?, '[]', '{}', 'heartbeat', 'queued', ?, null, null, null) + `).run(runId, orgId, taskId, agentId, providerId, `ctx_${runId}`, serviceIdentityId, new Date().toISOString()); + organization.setAutonomousPaused(true); + }); + + expect(isAutonomousPaused(connection)).toBe(true); + expect(snapshot.load().autonomousPaused).toBe(true); + + const runState = () => + (connection.db.prepare("select state from task_runs where id = ?").get(runId) as { state: string }).state; + + const stop = startTaskQueueWorker({ connection, mainWindow: fakeWindow, intervalMs: 10 }); + try { + // Paused: many ticks pass and the queued run is never claimed. + const claimedWhilePaused = await waitFor(() => runState() !== "queued", 150); + expect(claimedWhilePaused).toBe(false); + + // Resume: the very next ticks claim it (execution then fails on the + // unconfigured provider, which is fine - leaving "queued" proves the + // engine moved). + connection.transaction(() => organization.setAutonomousPaused(false)); + expect(snapshot.load().autonomousPaused).toBe(false); + const claimedAfterResume = await waitFor(() => runState() !== "queued", 2_000); + expect(claimedAfterResume).toBe(true); + } finally { + stop(); + } + connection.close(); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 7d05c030..580be2ab 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53]); connection.close(); }); diff --git a/tests/unit/file-helpers.test.ts b/tests/unit/file-helpers.test.ts index e3655d4f..6d8f3bab 100644 --- a/tests/unit/file-helpers.test.ts +++ b/tests/unit/file-helpers.test.ts @@ -8,7 +8,7 @@ function agent(id: string, department: string): AgentRecord { modelProviderId: null, modelId: null, budgetCents: 0, managerAgentId: null, title: id, roleLevel: "ic", modelStrategy: "single", workspaceId: null, heartbeatEnabled: false, heartbeatIntervalSec: 0, lifecycle: "active", - avatarEmoji: null, avatarColor: null, createdAt: "2026-01-01", + avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01", }; } diff --git a/tests/unit/profile-identity.test.ts b/tests/unit/profile-identity.test.ts index 48a825fa..7d444a16 100644 --- a/tests/unit/profile-identity.test.ts +++ b/tests/unit/profile-identity.test.ts @@ -122,4 +122,35 @@ describe("identity editing", () => { .toThrow("Display name is required."); h.connection.close(); }); + + test("stores uploaded avatar portraits and rejects anything but small data-URL images", () => { + const h = harness(); + const portrait = `data:image/png;base64,${"A".repeat(120)}`; + h.connection.transaction(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: portrait, + })); + expect((h.connection.db.prepare("select avatar_image from employees where id = ?").get(h.employeeId) as { avatar_image: string }).avatar_image) + .toBe(portrait); + // Clearing falls back to emoji/initials. + h.connection.transaction(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: "", + })); + expect((h.connection.db.prepare("select avatar_image from employees where id = ?").get(h.employeeId) as { avatar_image: string | null }).avatar_image) + .toBeNull(); + // Wrong scheme and oversized payloads are caller bugs, loudly rejected. + expect(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: "https://example.com/me.png", + })).toThrow("base64 data URL"); + expect(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, employeeId: h.employeeId, displayName: "Yang", avatarImage: `data:image/png;base64,${"A".repeat(300_001)}`, + })).toThrow("too large"); + // Agents take portraits through the same rules. + h.connection.transaction(() => h.organization.updateAgent({ + orgId: h.orgId, agentId: h.agentId, role: "CEO", title: "CEO", roleLevel: "c_suite", + department: "Executive", budgetCents: 25000, modelProviderId: null, modelId: null, avatarImage: portrait, + })); + expect((h.connection.db.prepare("select avatar_image from agents where id = ?").get(h.agentId) as { avatar_image: string }).avatar_image) + .toBe(portrait); + h.connection.close(); + }); });