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.
This commit is contained in:
1 parent
78e88807da
commit
cb71afa6b7
25 files changed
+373
-23
No files matched your search
@@ -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]);
|
||||
|
||||
@@ -126,6 +126,14 @@ export const zh: Record<string, string> = {
|
||||
"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": "办公室工具",
|
||||
|
||||
@@ -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 && (
|
||||
<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} />
|
||||
<AvatarField previewName={editingAgent.name} defaultEmoji={editingAgent.avatarEmoji} defaultColor={editingAgent.avatarColor} defaultImage={editingAgent.avatarImage} />
|
||||
<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>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onFileChosen = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Field label={t("Avatar")}>
|
||||
<div className="avatar-editor">
|
||||
<Avatar name={previewName || "?"} size="lg" look={{ emoji: emoji.trim() || null, color: color || null }} />
|
||||
<Avatar name={previewName || "?"} size="lg" look={{ emoji: emoji.trim() || null, color: color || null, image: image || null }} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
style={{ display: "none" }}
|
||||
aria-label={t("Avatar image file")}
|
||||
onChange={onFileChosen}
|
||||
/>
|
||||
<Button size="small" onClick={() => fileInputRef.current?.click()}>{t("Upload photo")}</Button>
|
||||
{image && <Button size="small" onClick={() => setImage("")}>{t("Remove photo")}</Button>}
|
||||
<input
|
||||
className="input avatar-emoji-input"
|
||||
name="avatarEmoji"
|
||||
@@ -53,7 +83,34 @@ export function AvatarField({
|
||||
))}
|
||||
</div>
|
||||
<input type="hidden" name="avatarColor" value={color} />
|
||||
<input type="hidden" name="avatarImage" value={image} />
|
||||
{uploadError && <div className="form-error" role="alert">{uploadError}</div>}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,18 @@ export function Shell({
|
||||
<span className="crumb">{activeLabel}</span>
|
||||
<div className="topbar-actions">
|
||||
<span className="topbar-actions-slot" ref={setActionsSlot} />
|
||||
<button
|
||||
type="button"
|
||||
className={`autopause-toggle ${snapshot.autonomousPaused ? "paused" : ""}`.trim()}
|
||||
aria-pressed={snapshot.autonomousPaused}
|
||||
title={snapshot.autonomousPaused
|
||||
? t("Agents are paused: no new runs start until you resume. Click to resume.")
|
||||
: t("Pause all agents: the run already in flight finishes, nothing new starts.")}
|
||||
onClick={() => void window.agentCompany.setAutonomousPaused({ paused: !snapshot.autonomousPaused })}
|
||||
>
|
||||
<span aria-hidden>{snapshot.autonomousPaused ? "▶" : "⏸"}</span>
|
||||
{snapshot.autonomousPaused ? t("Agents paused") : t("Pause agents")}
|
||||
</button>
|
||||
{!isMac && (
|
||||
<span className="window-controls">
|
||||
<IconButton icon="minus" label={t("Minimize")} onClick={() => 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 (
|
||||
<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} />
|
||||
<AvatarField previewName={employee.displayName} defaultEmoji={employee.avatarEmoji} defaultColor={employee.avatarColor} defaultImage={employee.avatarImage} />
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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 className={`avatar ${size}`.trim() + (bot && !emoji && !image ? " bot" : "")} style={{ background: bg, color: fg }}>
|
||||
{image ? (
|
||||
<img src={image} alt="" className="avatar-image" draggable={false} />
|
||||
) : emoji ? (
|
||||
<span aria-hidden>{emoji}</span>
|
||||
) : bot ? (
|
||||
<Icon name="bot" size={dim} />
|
||||
) : (
|
||||
initials(name)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user