diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 259f5634..6a9f1fcd 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -135,6 +135,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.deleteOrganization, (_event, input) => mutate(() => organization.deleteOrganization(input.orgId))); 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.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/database/migrations/049-avatars.ts b/src/main/database/migrations/049-avatars.ts new file mode 100644 index 00000000..0178095f --- /dev/null +++ b/src/main/database/migrations/049-avatars.ts @@ -0,0 +1,9 @@ +export const avatarsSchema = ` + alter table agents add column avatar_emoji text; + alter table agents add column avatar_color text; + alter table employees add column avatar_emoji text; + alter table employees add column avatar_color text; + + insert into schema_migrations(version, name, applied_at) + values (49, 'avatars', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 559aefc9..3306c4d4 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -47,6 +47,7 @@ import { connectorGrantsSchema } from "./migrations/045-connector-grants"; import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge"; import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge"; import { uiPreferencesSchema } from "./migrations/048-ui-preferences"; +import { avatarsSchema } from "./migrations/049-avatars"; interface Migration { version: number; @@ -103,6 +104,7 @@ const MIGRATIONS: Migration[] = [ { version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema }, { version: 47, name: "auth_config_purge", sql: authConfigPurgeSchema }, { version: 48, name: "ui_preferences", sql: uiPreferencesSchema }, + { version: 49, name: "avatars", sql: avatarsSchema }, ]; 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 c59cc041..9ef8ff05 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,7 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain"; import { createId } from "../../shared/ids"; -import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateTaskInput } from "../../shared/commands"; +import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff"; import { PolicyService } from "./policy-service"; @@ -147,11 +147,14 @@ export class OrganizationService { } updateAgent(input: UpdateAgentInput): void { - const agent = this.db.prepare("select id from agents where id = ? and org_id = ?") - .get(input.agentId, input.orgId) as { id: string } | undefined; + 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; if (!agent) throw new Error("Agent was not found."); const role = input.role.trim(); if (!role) throw new Error("Agent role is required."); + const name = input.name === undefined ? agent.name : input.name.trim(); + if (!name) throw new Error("Agent name is required."); + if (name !== agent.name) assertUsableMentionName(name); if (input.modelProviderId) { const provider = this.db.prepare("select id from providers where id = ? and org_id = ?") .get(input.modelProviderId, input.orgId) as { id: string } | undefined; @@ -159,11 +162,14 @@ export class OrganizationService { } const department = this.governance.requireCanonicalDepartment(input.orgId, input.department); 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 result = this.db.prepare(` update agents - set role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ? + set name = ?, role = ?, title = ?, role_level = ?, department = ?, budget_cents = ?, model_provider_id = ?, model_id = ?, + avatar_emoji = ?, avatar_color = ? where id = ? and org_id = ? - `).run(role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, input.agentId, input.orgId); + `).run(name, role, input.title.trim() || role, input.roleLevel.trim() || "staff", department, budgetCents, input.modelProviderId, input.modelId, avatarEmoji, avatarColor, input.agentId, input.orgId); if (result.changes === 0) throw new Error("Agent update failed."); this.audit.record({ orgId: input.orgId, @@ -172,7 +178,28 @@ export class OrganizationService { actorType: "employee", actorId: null, action: "updated", - payload: { role, roleLevel: input.roleLevel.trim() || "staff" }, + payload: { name, role, roleLevel: input.roleLevel.trim() || "staff" }, + }); + } + + 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; + 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); + this.audit.record({ + orgId: input.orgId, + entityType: "employee", + entityId: input.employeeId, + actorType: "employee", + actorId: input.employeeId, + action: "profile_updated", + payload: { displayName }, }); } @@ -479,3 +506,11 @@ export class OrganizationService { }); } } + +/** Avatar colors persist as #rrggbb; anything else is a caller bug. */ +function normalizeAvatarColor(value: string): string | null { + const color = value.trim(); + if (!color) return null; + if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new Error("Avatar color must be a #rrggbb hex value."); + return color.toLowerCase(); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index aa19603e..0494fe88 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ const api: DesktopApi = { deleteOrganization: (input) => invoke(IPC_CHANNELS.deleteOrganization, input), createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), + updateEmployeeProfile: (input) => invoke(IPC_CHANNELS.updateEmployeeProfile, 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 fb81d996..9b28536a 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Shell, type RouteId } from "../shared/Shell"; +import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui"; import { Onboarding } from "./Onboarding"; import { useSnapshot } from "./useSnapshot"; import { HomePage } from "../pages/HomePage"; @@ -16,6 +17,16 @@ import { OfficePage } from "../pages/OfficePage"; export function App() { const { snapshot, actions } = useSnapshot(); const [route, setRoute] = useState("home"); + const avatarDirectory = useMemo(() => { + const directory = new Map(); + for (const agent of snapshot?.agents ?? []) { + if (agent.avatarEmoji || agent.avatarColor) directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor }); + } + for (const employee of snapshot?.employees ?? []) { + if (employee.avatarEmoji || employee.avatarColor) directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor }); + } + return directory; + }, [snapshot?.agents, snapshot?.employees]); const [creatingOrg, setCreatingOrg] = useState(false); // Keeps the wizard mounted through its post-bootstrap connectors step: the // organization exists from Launch onward, so the org-count check alone @@ -68,15 +79,17 @@ export function App() { }[route]; return ( - void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))} - onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))} - onNewOrg={() => setCreatingOrg(true)} - > - {page} - + + void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))} + onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))} + onNewOrg={() => setCreatingOrg(true)} + > + {page} + + ); } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 1fb320dc..bee142ee 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -123,6 +123,13 @@ export const zh: Record = { "Edit message": "编辑消息", "Edit task": "编辑任务", "Edit {name}": "编辑 {name}", + "Edit profile": "编辑个人资料", + "Avatar": "头像", + "Avatar emoji": "头像表情", + "Avatar color": "头像颜色", + "Auto": "自动", + "Auto color": "自动配色", + "Color {color}": "颜色 {color}", "Employee": "成员", "Employees": "成员", "Error": "错误", diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 40246b78..f45bc1ea 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type FormEvent } from "react"; import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui"; +import { AvatarField } from "../shared/AvatarField"; import { TopbarActions } from "../shared/TopbarActions"; import { Dialog } from "../shared/Dialog"; import { OrgChartTree } from "./team/OrgChartTree"; @@ -82,6 +83,7 @@ export function TeamPage({ snapshot, actions }: PageProps) { void actions.run(() => window.agentCompany.updateAgent({ orgId, agentId: editingAgent.id, + name: text(form, "name"), role: text(form, "role"), title: text(form, "title") || text(form, "role"), roleLevel: text(form, "roleLevel") || "staff", @@ -89,6 +91,8 @@ export function TeamPage({ snapshot, actions }: PageProps) { budgetCents: Math.max(0, Math.round(Number(text(form, "budget")) * 100)), modelProviderId: optional(form, "modelProviderId"), modelId: optional(form, "modelId"), + avatarEmoji: String(form.get("avatarEmoji") ?? ""), + avatarColor: String(form.get("avatarColor") ?? ""), })).then((result) => { if (result) setEditAgentId(null); }); }; @@ -167,6 +171,8 @@ 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 new file mode 100644 index 00000000..c94c9a27 --- /dev/null +++ b/src/renderer/src/shared/AvatarField.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Avatar, AVATAR_COLORS, Field } from "./ui"; +import { t } from "../i18n/i18n"; + +/** + * 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. + */ +export function AvatarField({ + previewName, + defaultEmoji, + defaultColor, +}: { + previewName: string; + defaultEmoji: string | null; + defaultColor: string | null; +}) { + const [emoji, setEmoji] = useState(defaultEmoji ?? ""); + const [color, setColor] = useState(defaultColor ?? ""); + return ( + +
+ + setEmoji(event.currentTarget.value)} + /> +
+
+ +
+
+ ); +} diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx index 2c9ea2c4..4b87b624 100644 --- a/src/renderer/src/shared/Shell.tsx +++ b/src/renderer/src/shared/Shell.tsx @@ -1,6 +1,8 @@ -import { useEffect, useState, type ReactNode } from "react"; -import type { AppSnapshot } from "../../../shared/domain"; -import { Avatar, Icon, IconButton } from "./ui"; +import { useEffect, useState, type FormEvent, type ReactNode } from "react"; +import type { AppSnapshot, EmployeeRecord } from "../../../shared/domain"; +import { Avatar, Field, Icon, IconButton } from "./ui"; +import { AvatarField } from "./AvatarField"; +import { Dialog } from "./Dialog"; import { NavIcon } from "./NavIcon"; import { OrgSwitcher } from "./OrgSwitcher"; import { SearchDialog } from "./SearchDialog"; @@ -58,6 +60,7 @@ export function Shell({ children: ReactNode; }) { const [searchOpen, setSearchOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); const [actionsSlot, setActionsSlot] = useState(null); useEffect(() => { const handler = (event: KeyboardEvent) => { @@ -113,10 +116,18 @@ export function Shell({
- - {employee.displayName} +
+ {profileOpen && setProfileOpen(false)} />}
{organization.name} @@ -215,3 +226,33 @@ function countUnreadMessages(snapshot: AppSnapshot, orgId: string, employeeId: s } return unread; } + +/** + * The operator's own identity: display name plus avatar. Saves through the + * standard mutate IPC, so the refreshed snapshot repaints every surface that + * shows the user (sidebar, chat, org chart) at once. + */ +function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClose: () => void }) { + const [error, setError] = useState(null); + const submit = (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + void window.agentCompany.updateEmployeeProfile({ + orgId: employee.orgId, + employeeId: employee.id, + displayName: String(form.get("displayName") ?? "").trim(), + avatarEmoji: String(form.get("avatarEmoji") ?? ""), + avatarColor: String(form.get("avatarColor") ?? ""), + }).then((result) => { + if (result.ok) onClose(); + else setError(result.error.message); + }); + }; + return ( + + + + {error &&
{error}
} +
+ ); +} diff --git a/src/renderer/src/shared/ui.tsx b/src/renderer/src/shared/ui.tsx index c7c810dc..627274f0 100644 --- a/src/renderer/src/shared/ui.tsx +++ b/src/renderer/src/shared/ui.tsx @@ -1,6 +1,19 @@ -import type { ReactNode } from "react"; +import { createContext, useContext, type ReactNode } from "react"; import { Icon, type IconName } from "./Icon"; +export interface AvatarLook { + emoji: string | null; + color: string | null; +} + +/** + * Custom avatar looks by display name, built once from the snapshot (agents + * and employees). Avatar consumes it directly so every existing call site — + * chat rows, org chart, office, sidebar — honors a customized avatar without + * threading props through each page. + */ +export const AvatarDirectoryContext = createContext>(new Map()); + const palette: ReadonlyArray = [ ["#5e6ad2", "#fff"], ["#e8b339", "#231600"], @@ -12,6 +25,9 @@ const palette: ReadonlyArray = [ ["#c2410c", "#fff"], ]; +/** Swatch choices for custom avatar backgrounds — the hash palette, curated. */ +export const AVATAR_COLORS: readonly string[] = palette.map(([bg]) => bg); + function hashName(value: string): number { let hash = 0; for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) | 0; @@ -35,16 +51,24 @@ export function Avatar({ name, size = "", bot = false, + look, }: { name: string; size?: AvatarSize; bot?: boolean; + /** Explicit look override; when omitted the directory context is consulted. */ + look?: AvatarLook; }) { - const [bg, fg] = paletteFor(name); + const directory = useContext(AvatarDirectoryContext); + const custom = look ?? directory.get(name); + const [hashedBg, hashedFg] = paletteFor(name); + const bg = custom?.color || hashedBg; + const fg = custom?.color ? "#fff" : hashedFg; const dim = size === "sm" ? 14 : size === "lg" ? 18 : size === "xl" ? 24 : 14; + const emoji = custom?.emoji ?? null; return ( -
- {bot ? : initials(name)} +
+ {emoji ? {emoji} : bot ? : initials(name)}
); } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 433ae033..f02e8e07 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -240,3 +240,14 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } flex-shrink: 0; } @keyframes spinner-rotate { to { transform: rotate(360deg); } } + +/* Avatar editor (Team edit dialog + profile dialog) */ +.avatar-editor { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.avatar-emoji-input { width: 72px; text-align: center; } +.avatar-swatches { display: flex; gap: 6px; align-items: center; } +.avatar-swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; } +.avatar-swatch.selected { border-color: var(--accent); } +.avatar-swatch.auto { background: conic-gradient(#5e6ad2, #e8b339, #26b69a, #5e6ad2); } +.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; border-radius: var(--r-sm); } +.sidebar-foot-button:hover .sidebar-foot-name { text-decoration: underline; } +.form-error { color: var(--red); font-size: var(--fs-caption); margin-top: 4px; } diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 72ef894b..884fcdb4 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -27,6 +27,8 @@ export interface CreateOrganizationInput { export interface UpdateAgentInput { orgId: string; agentId: string; + /** New display name; omit to keep the current one. */ + name?: string; role: string; title: string; roleLevel: string; @@ -34,6 +36,18 @@ export interface UpdateAgentInput { budgetCents: number; modelProviderId: string | null; modelId: string | null; + /** Emoji shown on the avatar tile; empty string clears it. */ + avatarEmoji?: string; + /** Background color for the avatar tile; empty string clears it. */ + avatarColor?: string; +} + +export interface UpdateEmployeeProfileInput { + orgId: string; + employeeId: string; + displayName: string; + avatarEmoji?: string; + avatarColor?: string; } export interface CreateAgentInput { diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 65bb76c9..9bb77f97 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -7,6 +7,7 @@ import type { CancelProviderResponseInput, CreateAgentInput, UpdateAgentInput, + UpdateEmployeeProfileInput, CreateApprovalInput, CreateAutomationRuleInput, CreateDocumentInput, @@ -107,6 +108,7 @@ export interface DesktopApi { createOrganization(input: CreateOrganizationInput): Promise>; createAgent(input: CreateAgentInput): Promise>; updateAgent(input: UpdateAgentInput): Promise>; + updateEmployeeProfile(input: UpdateEmployeeProfileInput): 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 dbf80015..4c7acb09 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -49,6 +49,8 @@ export interface EmployeeRecord { orgId: string; displayName: string; role: string; + avatarEmoji: string | null; + avatarColor: string | null; createdAt: string; } @@ -69,6 +71,8 @@ export interface AgentRecord { heartbeatEnabled: boolean; heartbeatIntervalSec: number; lifecycle: string; + avatarEmoji: string | null; + avatarColor: string | null; createdAt: string; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index f490b852..0bb1bedf 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -7,6 +7,7 @@ export const IPC_CHANNELS = { createOrganization: "agentcompany:create-organization", createAgent: "agentcompany:create-agent", updateAgent: "agentcompany:update-agent", + updateEmployeeProfile: "agentcompany:update-employee-profile", createTask: "agentcompany:create-task", updateTask: "agentcompany:update-task", deleteTask: "agentcompany:delete-task", diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index bf73a209..81c6d211 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -85,6 +85,22 @@ test("onboards, hires, and creates work in the Electron shell", async () => { expect(hiredAgent.workspaceId).toBe(hiredAgent.primaryWorkspaceId); expect(hiredAgent.heartbeatEnabled).toBe(true); expect(hiredAgent.heartbeatIntervalSec).toBe(120); + + // The operator can rename any agent and give it a custom avatar. + await page.getByRole("button", { name: "Edit Alex Chen" }).click(); + await page.locator(".modal").getByLabel("Name", { exact: true }).fill("Alex Turing"); + await page.getByLabel("Avatar emoji").fill("🚀"); + await page.locator(".modal").getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Alex Turing").first()).toBeVisible(); + await expect(page.locator(".table-row", { hasText: "Alex Turing" }).locator(".avatar", { hasText: "🚀" })).toBeVisible(); + + // The operator edits their own name and avatar from the sidebar foot. + await page.getByRole("button", { name: "Edit profile" }).click(); + await page.locator(".modal").getByLabel("Name", { exact: true }).fill("Dana Zhang"); + await page.getByLabel("Avatar emoji").fill("🎯"); + 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("🎯"); await page.getByRole("button", { name: "Work", exact: true }).click(); await page.getByRole("button", { name: "New task" }).click(); await page.getByLabel("Title").fill("Production deploy"); @@ -158,12 +174,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Office" }).click(); await expect(page.locator("canvas")).toBeVisible(); - // @mention with canonical handle in chat + // @mention with canonical handle in chat (the renamed agent resolves). await page.getByRole("button", { name: "Messages" }).click(); await page.getByText("general").first().click(); - await page.getByRole("textbox", { name: "Message" }).fill("@alexchen what is the status?"); + await page.getByRole("textbox", { name: "Message" }).fill("@alexturing what is the status?"); await page.getByRole("button", { name: "Send" }).click(); - await expect(page.getByText("Alex Chen").first()).toBeVisible(); + await expect(page.getByText("Alex Turing").first()).toBeVisible(); await verifyPagesDoNotOverflow(page); } finally { diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 579622dc..ad92a395 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]); + 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]); connection.close(); }); diff --git a/tests/unit/file-helpers.test.ts b/tests/unit/file-helpers.test.ts index 343ec0fc..e3655d4f 100644 --- a/tests/unit/file-helpers.test.ts +++ b/tests/unit/file-helpers.test.ts @@ -7,7 +7,8 @@ function agent(id: string, department: string): AgentRecord { id, orgId: "org", name: id, role: "engineer", department, modelProviderId: null, modelId: null, budgetCents: 0, managerAgentId: null, title: id, roleLevel: "ic", modelStrategy: "single", workspaceId: null, - heartbeatEnabled: false, heartbeatIntervalSec: 0, lifecycle: "active", createdAt: "2026-01-01", + heartbeatEnabled: false, heartbeatIntervalSec: 0, lifecycle: "active", + avatarEmoji: null, avatarColor: null, createdAt: "2026-01-01", }; } diff --git a/tests/unit/profile-identity.test.ts b/tests/unit/profile-identity.test.ts new file mode 100644 index 00000000..229ecb1e --- /dev/null +++ b/tests/unit/profile-identity.test.ts @@ -0,0 +1,99 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; + +function harness(): { connection: DatabaseConnection; organization: OrganizationService; orgId: string; agentId: string; employeeId: string } { + const connection = new DatabaseConnection(join(tmpdir(), `ac-profile-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let agentId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + agentId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: null, modelId: null, budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite", + }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + return { connection, organization, orgId, agentId, employeeId }; +} + +describe("identity editing", () => { + test("renames an agent (the CEO included) and stores its avatar", () => { + const h = harness(); + h.connection.transaction(() => h.organization.updateAgent({ + orgId: h.orgId, + agentId: h.agentId, + name: "Nova Chen", + role: "CEO", + title: "Chief Executive Officer", + roleLevel: "c_suite", + department: "Executive", + budgetCents: 25000, + modelProviderId: null, + modelId: null, + avatarEmoji: "🚀", + avatarColor: "#26B69A", + })); + const row = h.connection.db.prepare("select name, avatar_emoji, avatar_color from agents where id = ?").get(h.agentId) as + { name: string; avatar_emoji: string; avatar_color: string }; + expect(row).toEqual({ name: "Nova Chen", avatar_emoji: "🚀", avatar_color: "#26b69a" }); + + // Clearing the avatar returns the tile to the hashed palette. + h.connection.transaction(() => h.organization.updateAgent({ + orgId: h.orgId, + agentId: h.agentId, + role: "CEO", + title: "Chief Executive Officer", + roleLevel: "c_suite", + department: "Executive", + budgetCents: 25000, + modelProviderId: null, + modelId: null, + avatarEmoji: "", + avatarColor: "", + })); + const cleared = h.connection.db.prepare("select name, avatar_emoji, avatar_color from agents where id = ?").get(h.agentId) as + { name: string; avatar_emoji: string | null; avatar_color: string | null }; + expect(cleared).toEqual({ name: "Nova Chen", avatar_emoji: null, avatar_color: null }); + h.connection.close(); + }); + + test("rejects blank names and malformed avatar colors", () => { + const h = harness(); + const base = { + orgId: h.orgId, + agentId: h.agentId, + role: "CEO", + title: "Chief Executive Officer", + roleLevel: "c_suite", + department: "Executive", + budgetCents: 25000, + modelProviderId: null, + modelId: null, + }; + expect(() => h.organization.updateAgent({ ...base, name: " " })).toThrow("Agent name is required."); + expect(() => h.organization.updateAgent({ ...base, avatarColor: "red" })).toThrow("Avatar color must be a #rrggbb hex value."); + h.connection.close(); + }); + + test("updates the operator's display name and avatar", () => { + const h = harness(); + h.connection.transaction(() => h.organization.updateEmployeeProfile({ + orgId: h.orgId, + employeeId: h.employeeId, + displayName: "Yang Zhang", + avatarEmoji: "🎯", + avatarColor: "#5e6ad2", + })); + const row = h.connection.db.prepare("select display_name, avatar_emoji, avatar_color from employees where id = ?").get(h.employeeId) as + { display_name: string; avatar_emoji: string; avatar_color: string }; + expect(row).toEqual({ display_name: "Yang Zhang", avatar_emoji: "🎯", avatar_color: "#5e6ad2" }); + expect(() => h.organization.updateEmployeeProfile({ orgId: h.orgId, employeeId: h.employeeId, displayName: " " })) + .toThrow("Display name is required."); + h.connection.close(); + }); +});