Make every identity editable: agent and operator names plus avatars
- Migration 049: avatar_emoji/avatar_color on agents and employees - updateAgent accepts a new name (mention-name validated) and avatar; new updateEmployeeProfile renames the operator and sets their avatar - Avatars are emoji-on-color tiles (renderer CSP blocks remote images); an AvatarDirectoryContext built from the snapshot lets the shared Avatar component honor custom looks at every call site - chat rows, conversation list, org chart, office card, sidebar - without threading props through pages - Team edit dialog gains Name + avatar editor (CEO included); the sidebar foot opens an Edit-profile dialog for the operator - e2e: rename an agent to a new mention handle with an emoji avatar, edit the operator profile, both verified in the live shell
This commit is contained in:
1 parent
86aa6fdb29
commit
e55b8b85b0
20 files changed
+377
-31
No files matched your search
@@ -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)));
|
||||
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<RouteId>("home");
|
||||
const avatarDirectory = useMemo(() => {
|
||||
const directory = new Map<string, AvatarLook>();
|
||||
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 (
|
||||
<Shell
|
||||
snapshot={snapshot}
|
||||
route={route}
|
||||
setRoute={setRoute}
|
||||
onSwitchOrg={(orgId) => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))}
|
||||
onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))}
|
||||
onNewOrg={() => setCreatingOrg(true)}
|
||||
>
|
||||
{page}
|
||||
</Shell>
|
||||
<AvatarDirectoryContext.Provider value={avatarDirectory}>
|
||||
<Shell
|
||||
snapshot={snapshot}
|
||||
route={route}
|
||||
setRoute={setRoute}
|
||||
onSwitchOrg={(orgId) => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))}
|
||||
onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))}
|
||||
onNewOrg={() => setCreatingOrg(true)}
|
||||
>
|
||||
{page}
|
||||
</Shell>
|
||||
</AvatarDirectoryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,13 @@ export const zh: Record<string, string> = {
|
||||
"Edit message": "编辑消息",
|
||||
"Edit task": "编辑任务",
|
||||
"Edit {name}": "编辑 {name}",
|
||||
"Edit profile": "编辑个人资料",
|
||||
"Avatar": "头像",
|
||||
"Avatar emoji": "头像表情",
|
||||
"Avatar color": "头像颜色",
|
||||
"Auto": "自动",
|
||||
"Auto color": "自动配色",
|
||||
"Color {color}": "颜色 {color}",
|
||||
"Employee": "成员",
|
||||
"Employees": "成员",
|
||||
"Error": "错误",
|
||||
|
||||
@@ -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 && (
|
||||
<Dialog key={editingAgent.id} title={t("Edit {name}", { name: editingAgent.name })} open submitLabel={t("Save")} onClose={() => setEditAgentId(null)} onSubmit={submitEdit}>
|
||||
<Field label={t("Name")}><input className="input" name="name" defaultValue={editingAgent.name} required /></Field>
|
||||
<AvatarField previewName={editingAgent.name} defaultEmoji={editingAgent.avatarEmoji} defaultColor={editingAgent.avatarColor} />
|
||||
<Field label={t("Role")}><input className="input" name="role" defaultValue={editingAgent.role} required /></Field>
|
||||
<Field label={t("Title")}><input className="input" name="title" defaultValue={editingAgent.title} /></Field>
|
||||
<Field label={t("Department")}><input className="input" name="department" defaultValue={editingAgent.department} required /></Field>
|
||||
|
||||
@@ -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 (
|
||||
<Field label={t("Avatar")}>
|
||||
<div className="avatar-editor">
|
||||
<Avatar name={previewName || "?"} size="lg" look={{ emoji: emoji.trim() || null, color: color || null }} />
|
||||
<input
|
||||
className="input avatar-emoji-input"
|
||||
name="avatarEmoji"
|
||||
value={emoji}
|
||||
maxLength={8}
|
||||
placeholder="🙂"
|
||||
aria-label={t("Avatar emoji")}
|
||||
onChange={(event) => setEmoji(event.currentTarget.value)}
|
||||
/>
|
||||
<div className="avatar-swatches" aria-label={t("Avatar color")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`avatar-swatch auto ${color === "" ? "selected" : ""}`.trim()}
|
||||
title={t("Auto")}
|
||||
aria-label={t("Auto color")}
|
||||
onClick={() => setColor("")}
|
||||
/>
|
||||
{AVATAR_COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={`avatar-swatch ${color === swatch ? "selected" : ""}`.trim()}
|
||||
style={{ background: swatch }}
|
||||
title={swatch}
|
||||
aria-label={t("Color {color}", { color: swatch })}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input type="hidden" name="avatarColor" value={color} />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
@@ -113,10 +116,18 @@ export function Shell({
|
||||
</button>
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<span className="sidebar-foot-name">{employee.displayName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-foot-button"
|
||||
aria-label={t("Edit profile")}
|
||||
onClick={() => setProfileOpen(true)}
|
||||
>
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<span className="sidebar-foot-name">{employee.displayName}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{profileOpen && <ProfileDialog employee={employee} onClose={() => setProfileOpen(false)} />}
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<span className="crumb-sub">{organization.name}</span>
|
||||
@@ -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<string | null>(null);
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Dialog title={t("Edit profile")} open submitLabel={t("Save")} onClose={onClose} onSubmit={submit}>
|
||||
<Field label={t("Name")}><input className="input" name="displayName" defaultValue={employee.displayName} required /></Field>
|
||||
<AvatarField previewName={employee.displayName} defaultEmoji={employee.avatarEmoji} defaultColor={employee.avatarColor} />
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ReadonlyMap<string, AvatarLook>>(new Map());
|
||||
|
||||
const palette: ReadonlyArray<readonly [string, string]> = [
|
||||
["#5e6ad2", "#fff"],
|
||||
["#e8b339", "#231600"],
|
||||
@@ -12,6 +25,9 @@ const palette: ReadonlyArray<readonly [string, string]> = [
|
||||
["#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 (
|
||||
<div className={`avatar ${size}`.trim() + (bot ? " bot" : "")} style={{ background: bg, color: fg }}>
|
||||
{bot ? <Icon name="bot" size={dim} /> : initials(name)}
|
||||
<div className={`avatar ${size}`.trim() + (bot && !emoji ? " bot" : "")} style={{ background: bg, color: fg }}>
|
||||
{emoji ? <span aria-hidden>{emoji}</span> : bot ? <Icon name="bot" size={dim} /> : initials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CancelProviderResponseInput,
|
||||
CreateAgentInput,
|
||||
UpdateAgentInput,
|
||||
UpdateEmployeeProfileInput,
|
||||
CreateApprovalInput,
|
||||
CreateAutomationRuleInput,
|
||||
CreateDocumentInput,
|
||||
@@ -107,6 +108,7 @@ export interface DesktopApi {
|
||||
createOrganization(input: CreateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateAgent(input: UpdateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateEmployeeProfile(input: UpdateEmployeeProfileInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateTask(input: UpdateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteTask(input: DeleteTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user