diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css
index 32efbd189..4e14777e7 100644
--- a/packages/chat-ui/src/styles.css
+++ b/packages/chat-ui/src/styles.css
@@ -832,6 +832,19 @@
border-radius: 50%;
}
+/* A human's generated fallback fill (`avatar-identity.ts`). react-ui's
+ `Avatar` takes no `style` prop — only `className` — so the two custom
+ properties this class reads are set on an ancestor by the caller and
+ inherit down through the DOM to whichever element carries this class.
+ Plain, unlayered CSS at equal specificity to react-ui's own
+ `bg-muted`/`text-muted-foreground` tone classes, positioned after them
+ in the cascade (this stylesheet imports after react-ui's), so it wins
+ without needing `!important`. */
+.avatar-identity-generated {
+ background-color: var(--avatar-identity-bg);
+ color: var(--avatar-identity-fg);
+}
+
/* A SenderAvatar rendered without the profile-button wrapper (the
streaming bubble) still occupies the same avatar column, so text
never shifts when the streamed reply finalizes into a persisted
diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx
index 5b6873123..053582b09 100644
--- a/packages/chat-ui/src/timeline.tsx
+++ b/packages/chat-ui/src/timeline.tsx
@@ -25,6 +25,7 @@ import {
toast,
} from "@corbits/react-ui";
import { toReactUiReasoning } from "./agent-part-adapter";
+import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity";
import { groupTimelineParts } from "./tool-activity";
import { ToolActivityGroup } from "./tool-activity-view";
import {
@@ -37,6 +38,7 @@ import {
PushPinSlash,
Smiley,
} from "@corbits/icons";
+import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
@@ -269,6 +271,10 @@ type SenderDisplay = {
readonly handle?: string;
readonly isAgent: boolean;
readonly initials: string;
+ /** The wire address behind this sender — never shown, only hashed
+ * (`generatedAvatarStyle`) into a stable per-person fallback color for
+ * a human's avatar. */
+ readonly id: string;
};
function senderDisplay(
@@ -283,7 +289,12 @@ function senderDisplay(
localPartOf(sender.address) === currentUser.principalId
) {
const label = currentUser.name ?? CHAT_STRINGS.senderYou;
- return { label, isAgent: false, initials: ownAvatarInitials(currentUser) };
+ return {
+ label,
+ isAgent: false,
+ initials: ownAvatarInitials(currentUser),
+ id: currentUser.principalId,
+ };
}
const matched = participants.find(
@@ -307,6 +318,7 @@ function senderDisplay(
handle: matched.handle,
isAgent,
initials: initialsOf(displayName ?? matched.handle),
+ id: matched.address,
};
}
@@ -315,6 +327,7 @@ function senderDisplay(
label: sender.name,
isAgent: false,
initials: initialsOf(sender.name),
+ id: sender.address,
};
}
@@ -322,33 +335,53 @@ function senderDisplay(
label: CHAT_STRINGS.senderFallbackMember,
isAgent: false,
initials: "?",
+ id: sender.address,
};
}
/** The message header's avatar chip — the same react-ui `Avatar` (tone by
* agent-vs-neutral, a tooltip carrying the full name) `chat-workspace.tsx`'s
- * member stack already uses, rather than a bespoke initials box. */
+ * member stack already uses, rather than a bespoke initials box. A human
+ * sender additionally gets `generatedAvatarStyle`'s deterministic
+ * per-person fill — set on this wrap (Avatar takes no `style` prop) and
+ * inherited into `Avatar`'s own root span through the
+ * `AVATAR_IDENTITY_CLASS` className — so every human reads as their own
+ * color instead of the same flat neutral gray agents already stand apart
+ * from. */
function SenderAvatar({
+ id,
initials,
label,
isAgent,
tenantMonogram,
tenantName,
}: {
+ id: string;
initials: string;
label: string;
isAgent: boolean;
tenantMonogram?: string;
tenantName?: string;
}) {
+ const identityStyle = isAgent
+ ? undefined
+ : (generatedAvatarStyle(id) as CSSProperties);
return (
-
+
{tenantMonogram !== undefined ? (
{display !== undefined && (
{(account) => (
)}
@@ -106,21 +112,43 @@ async function copyEmail(email: string): Promise {
* `BenchSectionView` is: directly renderable in tests without a fetch stub.
*/
export function AccountSectionView({
+ id,
name,
email,
emailVerified,
+ image,
onSignOut,
}: {
+ readonly id: string;
readonly name: string;
readonly email: string;
readonly emailVerified: boolean;
+ readonly image?: string;
readonly onSignOut?: () => void;
}) {
+ const fill = resolveAvatarFill(id, image);
return (
-
+ {fill.kind === "image" ? (
+

+ ) : (
+
+
+
+ )}
{name}
diff --git a/packages/settings-ui/src/styles.css b/packages/settings-ui/src/styles.css
index a74bc999b..9a82de48b 100644
--- a/packages/settings-ui/src/styles.css
+++ b/packages/settings-ui/src/styles.css
@@ -703,6 +703,18 @@
min-width: 0;
}
+/* An account's real profile picture (e.g. better-auth's `image`), shown
+ instead of the generated initials fallback whenever one is on hand —
+ same footprint as `Avatar`'s own `lg` size so the row doesn't reflow
+ depending on which a given account has. */
+.settings-account-avatar-image {
+ flex-shrink: 0;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
.settings-account-identity-text {
display: flex;
flex-direction: column;
diff --git a/packages/settings-ui/test/account-section.test.tsx b/packages/settings-ui/test/account-section.test.tsx
index 180686994..9e24aa145 100644
--- a/packages/settings-ui/test/account-section.test.tsx
+++ b/packages/settings-ui/test/account-section.test.tsx
@@ -40,6 +40,7 @@ describe("AccountSectionView", () => {
test("renders no Sign out action when the host gives no onSignOut", () => {
const el = mount(
{
let signedOut = false;
const el = mount(
{
test("shows an avatar and the name/email in the account card, and the same email again in the quieter details subsection", () => {
const el = mount(
{
try {
const el = mount(
{
test("AccountSectionView renders only the user's name and email, never a uuid", () => {
const markup = renderToStaticMarkup(
{
const report = auditUiVocabulary([
@@ -381,3 +385,35 @@ test("stripNonUserFacing preserves line and column positions", () => {
expect(stripped).not.toContain("hub");
expect(stripped).toContain("workbench");
});
+
+test("a className built from a literal plus an interpolation is not copy", () => {
+ expect(
+ findViolations([
+ {
+ relPath: "packages/chat-ui/src/timeline.tsx",
+ contents: "const c = `chat-sender-avatar ${AVATAR_IDENTITY_CLASS}`;",
+ },
+ ]),
+ ).toEqual([]);
+});
+
+test("a key with no real whitespace is not copy, even after blanking", () => {
+ expect(
+ findViolations([
+ {
+ relPath: "apps/web/src/pages/mission-control-page.tsx",
+ contents: "const k = `bench:${bench.id}`;",
+ },
+ ]),
+ ).toEqual([]);
+});
+
+test("real copy containing a banned term is still caught", () => {
+ const found = findViolations([
+ {
+ relPath: "apps/web/src/x.tsx",
+ contents: 'const s = "Open the chat to keep going.";',
+ },
+ ]);
+ expect(found.length).toBe(1);
+});
diff --git a/scripts/checks/ui-vocabulary.ts b/scripts/checks/ui-vocabulary.ts
index 291b3fc05..444a80fdb 100644
--- a/scripts/checks/ui-vocabulary.ts
+++ b/scripts/checks/ui-vocabulary.ts
@@ -156,12 +156,29 @@ function stripInterpolations(inner: string): string {
* tokens), an arktype/TS quoted-union type literal ('personal' |
* 'bench'), or a URL/API path. None of those are copy a user reads. */
function isProseLiteral(literal: string): boolean {
- const inner = literal.slice(1, -1);
- if (!/\s/.test(inner)) return false;
+ const raw = literal.slice(1, -1);
+ // Whitespace is judged on the literal as written: blanking an
+ // interpolation substitutes spaces, which would make a key like
+ // `` `bench:${id}` `` — no real whitespace, never prose — look like it.
+ if (!/\s/.test(raw)) return false;
+ // Shape, though, is judged with interpolations blanked, since they are
+ // code the user never sees: otherwise a className like `` `a-b ${X}` ``
+ // tokenizes as ["a-b", "${X}"] and the class-list exemption never applies.
+ const inner = stripInterpolations(raw);
if (QUOTED_UNION.test(inner.trim())) return false;
if (inner.trim().startsWith("/")) return false;
const tokens = inner.trim().split(/\s+/);
- if (tokens.length > 1 && tokens.every((token) => KEBAB_TOKEN.test(token))) {
+ // A className built from a literal plus an interpolation — `` `a-b ${X}` ``
+ // — blanks down to one token, so the multi-token rule alone would read it
+ // as prose. A single token only counts as a class when it is hyphenated:
+ // a bare lowercase word like "workbench" is exactly the copy this check
+ // exists to catch.
+ const everyTokenIsClassLike = tokens.every((token) =>
+ KEBAB_TOKEN.test(token),
+ );
+ const looksLikeClassList =
+ tokens.length > 1 || (tokens[0] ?? "").includes("-");
+ if (everyTokenIsClassLike && looksLikeClassList) {
return false;
}
return true;