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:
ZacharyZhang-NY committed 2026-07-08 21:33:23 -04:00
1 parent 86aa6fdb29
commit e55b8b85b0
20 files changed
+377 -31

No files matched your search

+24 -11
View File
@@ -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>
);
}
+7
View File
@@ -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": "错误",
+6
View File
@@ -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>
+59
View File
@@ -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>
);
}
+46 -5
View File
@@ -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>
);
}
+28 -4
View File
@@ -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>
);
}
+11
View File
@@ -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; }