Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
73f2ea9
fix(avatars): scale agent squircles from normalized paths
tellaho Sep 3, 2026
1355a0e
fix(avatars): reveal desktop squircle clipping
tellaho Sep 3, 2026
641fafa
fix(avatars): preserve squircle badge masking
tellaho Sep 3, 2026
30530e4
fix(avatars): tighten shape-aware facepile masks
tellaho Sep 4, 2026
c7aa70d
fix(desktop): add breathing room to thread summary
tellaho Sep 4, 2026
407e83b
fix(avatars): remove action pill cutouts
tellaho Sep 4, 2026
f7a52f1
fix(avatars): clip team facepile outlines
tellaho Sep 4, 2026
2437fe0
fix(avatars): preserve square emoji artwork
tellaho Sep 4, 2026
69e2412
fix(avatars): clip empty team outlines
tellaho Sep 8, 2026
b6fe874
fix(avatars): replace circular starter portraits
tellaho Sep 8, 2026
448ac8b
refactor(avatars): register the squircle utility
tellaho Sep 8, 2026
2f9def3
revert(mobile): defer normalized avatar clipping
tellaho Sep 9, 2026
978f161
fix(desktop): preserve legacy emoji avatar artwork
tellaho Sep 9, 2026
a4a7218
fix(desktop): keep avatar decoration outside clips
tellaho Sep 9, 2026
7cb09cd
fix(huddle): align agent speaking outline
tellaho Sep 9, 2026
f321bee
fix(desktop): preserve agent avatar focus rings
tellaho Sep 9, 2026
413cc3b
fix(avatars): reject nested legacy emoji markup
tellaho Sep 9, 2026
5bfffb1
fix(desktop): preserve interactive avatar focus rings
tellaho Sep 9, 2026
e1c8947
fix(desktop): keep add-avatar foreground visible
tellaho Sep 10, 2026
64371c2
fix(desktop): keep avatar control shells unclipped
tellaho Sep 10, 2026
fe7e03f
fix(desktop): preserve project facepile separators
tellaho Sep 10, 2026
636d6bc
fix(desktop): isolate project facepile separators
tellaho Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas.rs

Large diffs are not rendered by default.

40 changes: 31 additions & 9 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ struct LegacyBuiltInAvatar<'a> {
}

const LEGACY_BUILTIN_AVATARS: &[LegacyBuiltInAvatar<'static>] = &[
// Original circular portraits replaced in #2215. Keep these entries so an
// install that skips directly over that release still migrates cleanly.
LegacyBuiltInAvatar {
persona_id: "builtin:fizz",
data_url_sha256: "2771b8c9c46aa3c8ac1c4d2acfa23fa9ba35b79c4b1694554e923081e3b8b4d0",
Expand All @@ -543,6 +545,25 @@ const LEGACY_BUILTIN_AVATARS: &[LegacyBuiltInAvatar<'static>] = &[
sanitized_media_sha256: "9f798c61f8965b80beb808f505feb0a5b33726545188ea8212cc9ab22d05f0b6",
persona_content_hash: "544a73f9106a3c8848b0f308b7a8b6f95077ac8deccdb9ed5552caa833d66c95",
},
// Circular 384px portraits replaced by the square 128px agent artwork.
LegacyBuiltInAvatar {
persona_id: "builtin:fizz",
data_url_sha256: "6cdf8c338d27ef3c9dd68cf4839f3d62dec2298e2d4e07b851f26f8ba289b377",
sanitized_media_sha256: "e6025a21c864750f4b3828c9035bafdbe68ec73bd247745c78309b82fedcbed5",
persona_content_hash: "d30d9c7105be0765a2c5ebe89fdb67842201409e79a4b7937f46e2772411d925",
},
LegacyBuiltInAvatar {
persona_id: "builtin:honey",
data_url_sha256: "7b51bb3e353aed13b5d6a01d0ad9aa58d63b6bb347f2ed6ee891e7af6149febe",
sanitized_media_sha256: "4a64f32d2375ccfe44c0880416bc72b809b055f0fb7d9dddeb8bb3dd6b80c297",
persona_content_hash: "e0f88abf79d6826973cc99373ddd1f8350bed1df51064f7b1a0d7e3d7984c49e",
},
LegacyBuiltInAvatar {
persona_id: "builtin:bumble",
data_url_sha256: "9e1e841a76069202474393b786ada0628d90711703f8ba96c9ecee84a1c6a593",
sanitized_media_sha256: "99d32de5791ec07db70c761db30fd145422e6c8200ba8d189363c3ce53273758",
persona_content_hash: "783ee80629046d28f4f07095b1ceedab129661d40a2d5560806bc487a1a65a11",
},
];

struct LegacyAvatarMatch<'a> {
Expand Down Expand Up @@ -674,20 +695,21 @@ fn legacy_avatar_match<'a>(
.get("persona_id")
.and_then(serde_json::Value::as_str)
.or_else(|| record.get("slug").and_then(serde_json::Value::as_str))?;
let metadata = legacy_avatars
.iter()
.find(|legacy| legacy.persona_id == persona_id)?;
let current_avatar = record
.get("avatar_url")
.and_then(serde_json::Value::as_str)?;
let matches_data_url =
hex::encode(Sha256::digest(current_avatar.as_bytes())) == metadata.data_url_sha256;
let matches_uploaded_media = uploaded_media_sha256(current_avatar)
.is_some_and(|sha256| sha256 == metadata.sanitized_media_sha256);
(matches_data_url || matches_uploaded_media).then(|| LegacyAvatarMatch {
let data_url_sha256 = hex::encode(Sha256::digest(current_avatar.as_bytes()));
let uploaded_media_sha256 = uploaded_media_sha256(current_avatar);
let metadata = legacy_avatars.iter().find(|legacy| {
legacy.persona_id == persona_id
&& (legacy.data_url_sha256 == data_url_sha256
|| uploaded_media_sha256.as_deref() == Some(legacy.sanitized_media_sha256))
})?;
let was_uploaded = uploaded_media_sha256.as_deref() == Some(metadata.sanitized_media_sha256);
Some(LegacyAvatarMatch {
persona_id: persona_id.to_string(),
metadata,
was_uploaded: matches_uploaded_media,
was_uploaded,
})
}

Expand Down
34 changes: 34 additions & 0 deletions desktop/src-tauri/src/migration_avatar_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,40 @@ fn current_builtin_agent_avatars_do_not_match_legacy_hashes() {
}
}

#[test]
fn legacy_avatar_match_checks_every_known_generation_for_a_persona() {
use sha2::{Digest as _, Sha256};

let prior_avatar = "data:image/png;base64,prior-fizz";
let prior_hash = hex::encode(Sha256::digest(prior_avatar.as_bytes()));
let legacy_avatars = [
LegacyBuiltInAvatar {
persona_id: "builtin:fizz",
data_url_sha256: "older-fizz-hash",
sanitized_media_sha256: "older-upload-hash",
persona_content_hash: "older-persona-version",
},
LegacyBuiltInAvatar {
persona_id: "builtin:fizz",
data_url_sha256: prior_hash.as_str(),
sanitized_media_sha256: "prior-upload-hash",
persona_content_hash: "prior-persona-version",
},
];
let record = serde_json::json!({
"slug": "builtin:fizz",
"avatar_url": prior_avatar,
});

let matched = legacy_avatar_match(&record, &legacy_avatars).unwrap();

assert_eq!(
matched.metadata.persona_content_hash,
"prior-persona-version"
);
assert!(!matched.was_uploaded);
}

#[test]
fn refresh_builtin_agent_avatars_updates_versions_without_stored_definitions() {
use sha2::{Digest as _, Sha256};
Expand Down
57 changes: 38 additions & 19 deletions desktop/src/features/agents/ui/AgentCreationPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorP
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ROUNDED_SQUIRCLE_PATH } from "@/shared/ui/AvatarClipPaths";
import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider";
import {
Popover,
Expand Down Expand Up @@ -107,7 +108,6 @@ export function AgentCreationPreview({
assetLabel.charAt(0).toUpperCase() + assetLabel.slice(1);
const isRoundedSquare = shape === "rounded-square";
const isCompact = variant === "compact";
const emojiShape = isRoundedSquare ? "rounded-square" : "circle";
const {
inputRef: avatarUploadInputRef,
isUploading,
Expand Down Expand Up @@ -197,11 +197,7 @@ export function AgentCreationPreview({
if (!isCustomColorPickerOpen || !selectedEmoji) {
return;
}
const nextAvatarUrl = emojiAvatarDataUrl(
selectedEmoji,
customColorDraft,
emojiShape,
);
const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft);
if (avatarUrl === nextAvatarUrl) {
return;
}
Expand All @@ -212,7 +208,6 @@ export function AgentCreationPreview({
isCustomColorPickerOpen,
onSelectAvatar,
selectedEmoji,
emojiShape,
]);

function applyAvatarUrl() {
Expand All @@ -227,7 +222,7 @@ export function AgentCreationPreview({
}

function applyEmojiAvatar(emoji: string, color = selectedColor) {
const nextAvatarUrl = emojiAvatarDataUrl(emoji, color, emojiShape);
const nextAvatarUrl = emojiAvatarDataUrl(emoji, color);
onSelectAvatar(nextAvatarUrl);
onCommitAvatar?.(nextAvatarUrl);
setSquishKey((key) => key + 1);
Expand Down Expand Up @@ -724,7 +719,7 @@ export function AgentCreationPreview({
? isCompact
? "rounded-2xl"
: "rounded-[2rem]"
: "rounded-[30%]",
: "rounded-squircle",
)}
role="img"
style={{ backgroundColor: emojiAvatarPreview.color }}
Expand Down Expand Up @@ -924,19 +919,19 @@ export function AgentCreationPreview({
}
className={isCompact ? "h-16 w-16" : "h-36 w-36"}
clipTestId={`${testIdPrefix}-mask`}
cornerRadius={(isCompact ? 64 : 144) * 0.3}
cutout={
isCompact
? { cx: 58, cy: 58, r: 16.5 }
: { cx: 123, cy: 123, r: 24 }
}
maskMode={isCompact ? "radial" : "clip-path"}
shape="squircle"
size={isCompact ? 64 : 144}
>
{emojiAvatarPreview ? (
<div
aria-label={`${label} ${assetLabel}`}
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-[30%] shadow-xs transition-[background-color] duration-200 ease-out"
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-squircle shadow-xs transition-[background-color] duration-200 ease-out"
role="img"
style={{
backgroundColor: emojiAvatarPreview.color,
Expand Down Expand Up @@ -979,30 +974,54 @@ export function AgentCreationPreview({
<button
aria-label={`Add ${assetLabel}`}
className={cn(
"flex items-center justify-center border-2 border-dashed border-border bg-background text-primary shadow-xs transition-[background-color,border-color,color,box-shadow,scale] duration-150 ease-out hover:scale-[1.02] hover:border-primary/60 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-60 disabled:hover:scale-100",
"group/add-avatar relative flex items-center justify-center bg-transparent text-primary shadow-xs transition-[background-color,border-color,color,filter,scale] duration-150 ease-out hover:scale-[1.02] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-60 disabled:hover:scale-100",
isCompact ? "h-16 w-16" : "h-36 w-36",
isRoundedSquare
? isCompact
? "rounded-2xl"
: "rounded-[2rem]"
: "rounded-[30%]",
? cn(
"border-2 border-dashed border-border hover:border-primary/60 hover:bg-primary/5",
isCompact ? "rounded-2xl" : "rounded-[2rem]",
)
: "border-0",
isDragOverAvatar &&
!isAvatarMenuOpen &&
"border-primary/70 bg-primary/5 ring-2 ring-primary/15",
(isRoundedSquare
? "border-primary/70 bg-primary/5 ring-2 ring-primary/15"
: "ring-2 ring-primary/30"),
)}
disabled={disabled || isUploading}
title={`Add ${assetLabel}`}
type="button"
>
{isRoundedSquare ? null : (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-squircle bg-background text-border transition-colors duration-150 ease-out group-hover/add-avatar:bg-primary/5 group-hover/add-avatar:text-primary/60"
data-testid={`${testIdPrefix}-empty-outline`}
preserveAspectRatio="none"
viewBox="0 0 1 1"
>
<path
d={ROUNDED_SQUIRCLE_PATH}
fill="none"
pathLength="1"
stroke="currentColor"
strokeDasharray="0.035 0.035"
strokeWidth="0.018"
/>
</svg>
)}
{isUploading ? (
<Spinner
aria-label={`Uploading ${assetLabel}`}
className="h-4 w-4 border-2"
className="relative z-10 h-4 w-4 border-2"
/>
) : (
<Plus
aria-hidden="true"
className={isCompact ? "h-6 w-6" : "h-14 w-14"}
className={cn(
"relative z-10",
isCompact ? "h-6 w-6" : "h-14 w-14",
)}
/>
)}
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,12 @@ export function AgentRuntimeAvatarControl({
: "bg-primary",
)}
className="h-24 w-24"
cornerRadius={AGENT_AVATAR_SIZE * 0.3}
clipTestId="agent-runtime-avatar-mask"
shape="squircle"
curve={showStatusDot ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
cutout={badge.cutout}
cutoutWidth={actionCutoutWidth}
maskMode={showStatusDot ? "clip-path" : "none"}
maskTransition={transition}
size={AGENT_AVATAR_SIZE}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function IdentityInitialsAvatar({
return (
<span
className={cn(
"flex h-full w-full items-center justify-center rounded-[30%] border-[3px] border-background font-semibold shadow-sm",
"flex h-full w-full items-center justify-center rounded-squircle border-[3px] border-background font-semibold shadow-sm",
colorClassName,
textSizeClassName,
className,
Expand Down
13 changes: 9 additions & 4 deletions desktop/src/features/agents/ui/TeamIdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,13 @@ function TeamAvatarRow({
if (visiblePersonas.length === 0 && overflowCount === 0) {
return (
<div className="absolute inset-x-4 top-0 bottom-12 flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-[30%] border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
<Users className="h-9 w-9" />
<div className="relative h-24 w-24 before:rounded-squircle before:absolute before:-inset-px before:bg-border/65 before:content-['']">
<div
className="relative z-10 flex h-full w-full items-center justify-center rounded-squircle bg-background/80 text-muted-foreground shadow-xs"
data-team-empty-avatar="avatar"
>
<Users className="h-9 w-9" />
</div>
</div>
</div>
);
Expand All @@ -142,7 +147,7 @@ function TeamAvatarRow({
className={visiblePersonas.length > 0 ? "-ml-5" : ""}
style={{ zIndex: stackItemCount }}
>
<span className="flex h-14 w-14 items-center justify-center rounded-[30%] bg-card text-sm font-semibold text-muted-foreground ring-2 ring-card">
<span className="flex h-14 w-14 items-center justify-center rounded-squircle bg-card text-sm font-semibold text-muted-foreground ring-2 ring-card">
+{overflowCount}
</span>
</div>
Expand All @@ -163,7 +168,7 @@ function TeamAvatarItem({

return (
<div
className={`relative h-14 w-14 before:absolute before:-inset-0.5 before:rounded-[calc(30%+2px)] before:bg-card before:content-[''] ${index > 0 ? "-ml-5" : ""}`}
className={`relative h-14 w-14 before:rounded-squircle before:absolute before:-inset-0.5 before:bg-card before:content-[''] ${index > 0 ? "-ml-5" : ""}`}
data-team-member-avatar="avatar"
style={{
zIndex: index + 1,
Expand Down
18 changes: 13 additions & 5 deletions desktop/src/features/agents/ui/managedAgentAvatar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,22 @@ test("resolveManagedAgentAvatarUrl uploads data image URIs", async () => {
assert.equal(uploaded, "https://relay.example/avatar.png");
});

test("resolveManagedAgentAvatarUrl passes emoji svg data URLs through", async () => {
const emojiUrl =
"data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E";
const uploaded = await resolveManagedAgentAvatarUrl(emojiUrl, async () => {
test("resolveManagedAgentAvatarUrl squares legacy emoji svg data URLs", async () => {
const legacySvg =
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="256" fill="#ffcc00"/><text x="50%" y="56%" dominant-baseline="middle" text-anchor="middle" font-size="258">🐝</text></svg>';
const emojiUrl = `data:image/svg+xml,${encodeURIComponent(legacySvg)}`;
const resolved = await resolveManagedAgentAvatarUrl(emojiUrl, async () => {
throw new Error("should not upload inline emoji svg data URLs");
});

assert.equal(uploaded, emojiUrl);
assert.ok(resolved);
const normalizedSvg = decodeURIComponent(resolved.split(",", 2)[1]);
assert.match(
normalizedSvg,
/<rect width="512" height="512" fill="#ffcc00"\/>/u,
);
assert.doesNotMatch(normalizedSvg, /\brx=/u);
assert.match(normalizedSvg, />🐝<\/text>/u);
});

test("resolveManagedAgentAvatarUrl passes non-data URLs through", async () => {
Expand Down
9 changes: 6 additions & 3 deletions desktop/src/features/agents/ui/managedAgentAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { squareEmojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils";

type BlobDescriptor = {
url: string;
sha256: string;
Expand All @@ -23,10 +25,11 @@ export async function resolveManagedAgentAvatarUrl(

// Emoji avatars are stored as inline, percent-encoded SVG data URLs
// (`data:image/svg+xml,%3C...`) — the same self-contained form profile
// persists. They are not base64 and must not be run through `atob`/upload;
// pass them through unchanged so the emoji survives agent creation.
// persists. They are not base64 and must not be run through `atob`/upload.
// Normalize legacy rounded source artwork to a square while creating or
// editing an agent so the consuming squircle owns the complete silhouette.
if (!isBase64DataUri(resolvedAvatarUrl)) {
return resolvedAvatarUrl;
return squareEmojiAvatarDataUrl(resolvedAvatarUrl);
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test("resolveSnapshotAvatarPng: emoji SVG is rasterized onto a canvas", async ()
};

const emojiSvg =
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="256" fill="#ffcc00"/><text>✨</text></svg>';
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="256" fill="#ffcc00"/><text x="50%" y="56%" dominant-baseline="middle" text-anchor="middle" font-size="258">✨</text></svg>';
const result = await resolveSnapshotAvatarPng(
`data:image/svg+xml,${encodeURIComponent(emojiSvg)}`,
{
Expand Down
Loading
Loading