Skip to content

Let a Bot's owner choose its colour and draw it a new face - #914

Open
jerelvelarde wants to merge 34 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-bot-look
Open

jerelvelarde wants to merge 34 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-bot-look

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Let a Bot's owner choose its colour and draw it a new face

Parked with #913 until after 1.0. It colours the face #913 draws, so it can only land on top of #913. Nothing else depends on it.

Depends on #913. Its own change is the commits after that one: compare.

The problem

With #913, every Bot is an orb whose colour and face come from a hash of its avatar seed, and nobody can change the seed. agent_profiles.avatar_seed is set when a Bot is created or copied, and the edit route never reads it. A person whose two Bots landed in the same orange, or who wants the Bot that books travel to be blue, has no way to say so. The only lever is a tenant package's avatar_seed, which reaches only the package's own Bots.

Conversation faces have a second gap. ChannelAvatar knows only participant ids and draws each face from the id, which matches the seed only until somebody changes it.

The approach

The colour travels in the seed, so nothing is migrated. A seed may start with one of the eight Bot colours and a colon, as moss:k2p9x7aq; the orb draws that colour and takes the rest of its look from the hash of the whole seed. Every other seed draws exactly as before, and a test pins the shipped Bots' looks. The catalog's On-call Handover now names one (coral:oncall-handover), so the ten catalog Bots use all eight. violet: names none. The colours and the parse live in shared/bot-look.ts, which the server and the orb both read, so the server never accepts a look the orb would draw differently.

The edit route takes it. PATCH /api/agents/:agentId takes an optional avatarSeed: 1 to 64 letters, digits, hyphens, underscores or colons, with a colour prefix only when a Bot can have that colour. A refusal is a 400 with one sentence, such as "Violet is kept for OpenBot. Pick another colour." Absent leaves the face as it is, so an edit to anything else never restyles a Bot. The store's existing manage check covers it, so only someone who can rename the Bot can change its look, and never for a Bot a tenant package owns. A new look is named on the bot.updated audit row; it is a picture, not a secret.

The Setup page has a Look section for whoever may edit the Bot: its face at 72px, the eight colours as radios named for what each draws (Orange to Grey, with Cyan and Red), and New face, which keeps the colour. Arrowing through the colours previews each on the face; the one the owner stops on saves once, 700ms later, or when focus leaves the row or the page goes away. Browsing writes one PATCH and one audit row, not one per colour passed. Undo puts back the look from before, and a polite live region says what changed: "Blue, new face", "Green, saved". A failed save goes back and says so. The swatches' focus ring is full strength, to clear 3:1.

Conversation faces read the seed from the Bots list. useBotSeeds maps each id to the seed in the list the app already holds, so a new look reaches the sidebar, a conversation's header and its messages. Until the list arrives the circle holds no face, rather than one about to change.

An edited Bot keeps its place. The Bots list had no order, so an edit could move a Bot from first to last and change the default, which falls back to the first. list() now orders by creation, with the id breaking ties, because a tenant package writes all its Bots in one transaction.

The cost: a conversation's faces now wait on GET /api/agents, and a seed carries meaning in its spelling, so anything that writes one should go through shared/bot-look.ts.

Before After
A Bot's Setup page before A Bot's Setup page with its Look section

What is not covered

  • Eight colours and no others. Violet stays OpenBot's.
  • A seed spells its colour by the palette's name, not the screen's: coral: draws Red and sky: Cyan.
  • Undo reaches back one change, and only while the page stays open.
  • A Bot the reader's list doesn't hold, such as a hidden one, falls back to its id in a conversation, so a restyled Bot shows its old face there.
  • Create takes no seed; a new Bot is restyled afterwards. A tenant package's Bots keep the package's look.
  • In WebKit, so Safari and the desktop app on a Mac, Tab skips ordinary buttons by default across the app. The swatches are one Tab stop there too; the rest is the browser's setting.

Verification

typecheck, lint, format:check and the assertion-shape guard are clean. The full app suite is 2226 pass, 0 fail, against 2206 on #913 and 2129 on upstream main. CI's startup job passes on macOS and Windows. In Chromium, WebKit and Firefox, at 1280 and 390 wide, every changed screen loads with no page error and no sideways scroll.

  • shared/bot-look.test.ts: a seed names one of the eight and never violet; the server refuses anything else in a plain sentence.
  • app/tests/bot-look.test.tsx: arrowing previews and saves only the colour it stops on, once; arrowing back saves nothing; a colour in its pause saves when the page goes away; Undo before and after a save; a failed save goes back; with faces Still a pick still shows; a conversation's face waits for the list.
  • app/tests/bot-orb.test.tsx, extended: a Bot that never chose keeps its face; violet and unknown colours are refused; the catalog shares as few colours as it can.
  • app/tests/visual-system.test.ts, extended: the swatches' ring clears 3:1.
  • server/tests/agent-profile-store.integration.test.ts, extended: a look changes only when an edit brings one, only for whoever may manage the Bot; an edited Bot keeps its place.
  • server/tests/agent-routes.test.ts, bot-lifecycle-audit.test.ts, extended: the route and the audit row.
  • app/tests/bot-look.test.tsx also: the swatches are one Tab stop, the chosen one, so WebKit reaches them.

Merge notes

This and #916 both still change app/src/components/channels/avatar.tsx and app/tests/user-preferences.test.tsx, and a trial merge conflicts in both. Whichever merges second keeps this branch's useBotSeeds with #916's state and status dot, and gives app/tests/channel-avatar.test.tsx and the pane-title test in app/tests/computer-pane.test.tsx a query client holding the Bots list, since a face now waits for it.

The last two places still drawing boring-avatars' gradient circles now draw the
orb in the colour the Bot's id picks, so a Bot looks the same in the sidebar,
the conversation header and its profile. Nothing imports boring-avatars any
more, so it leaves the app's dependencies.
…ll when asked

A Bot's orb is a block, so a group conversation's 12px faces no longer sit on
the header's text baseline and drop half out of their circles. It resizes its
canvas when the screen's pixel density changes, so dragging a window to another
screen or zooming no longer leaves it soft, and it follows the reduced-motion
setting live rather than only when it first draws. The changelog and the
stylesheet now describe what this step draws, not what later steps will.
The skins import the orb's state type from agent-orb now that ai-core is gone,
OpenBot's mark keeps its agent-orb class for them, and their tests check the
orb's colour rather than boring-avatars' SVG. The Appearance test's preferences
carry the new skin field.
AgentOrb read "100%" as 100px, so with no Bot yet the header drew a 100px orb
in a 44 or 52px slot. With a Bot, the classes that used to rescale
boring-avatars' SVG squeezed the avatar's circle to 44px below 900px wide while
the orb inside stayed 52px, clipped and off centre. The frame now picks 44 or
52px from the window's width and draws the face at that size, the selectors
that matched only the old SVG are gone here and from OpenDots' Bot rows, and
AgentOrb takes its size only in px and refuses anything else.
Every orb on screen redrew on every animation frame, uncapped, so a sidebar of
conversations kept a few dozen canvases repainting at the screen's full rate
while the app sat idle: a small orb's colour still drifted, so no two frames
were ever the same. A small orb's colour now holds still, an orb skips any
frame that would draw what its canvas already shows in the same theme and at
the same density, and a small orb draws at most about thirty frames a second.
A face at rest with the pointer still now draws only to blink.
…violet

A Bot's face was one of nine colours and nothing else, so a team of four had
about an even chance of two identical faces. In the Bots OpenBot ships with,
three were the same sky blue, and release notes wore violet, OpenBot's own
mark. A Bot is now drawn from its seed: one of the eight colours that aren't
violet, with its colour masses turned, its hue nudged and its eyes spaced by
the same hash, so two Bots in one colour still look apart. BotOrb takes the
seed rather than a colour and draws OpenBot's violet when it has none. The
OpenDots avatar test checked one colour in nine, so it passed or failed on the
hash; it now checks that the seed reaches the orb.
… Bots apart

At 28px an orb reads as the average of its colours, and several averages sat
side by side: coral beside ember, sky beside ocean, berry beside violet. The
two Bots every deployment ships, general-assistant and knowledge, drew as
coral and ember and looked alike, and sun read bronze at 72px. The eight
colours keep their names but are re-tuned to read as red, orange, yellow,
green, cyan, blue, pink and grey, with lightness keeping neighbours apart.
orbSwatch gives the colour an orb reads as when small, and a test holds every
pair, violet included, at least 0.08 apart in OKLab. The colour now takes the
hash's top byte, so the shipped Bots draw yellow, red, cyan and grey; a test
pins that. Every Bot's face changes once, before this merges.
The outline every element falls back on, and OpenMuse's header and tab focus
rings, drew the violet ring at half strength with no border beside it: about
2.4:1 on the light background and 2.8:1 in the dark, under WCAG's 3:1 for a
focus indicator. They now draw it at full strength. The visual-system test
blends each ring at the strength its class gives it over the background and a
card, in both themes, and fails under 3:1.
…hem all

Every orb blinked about every four seconds and watched the pointer forever;
only the system's reduced motion stopped it, so a list of Bots never sat
still (WCAG 2.2.2). An orb under 40px now never follows the pointer and is
still at rest: it moves only while it listens, works or lands, and holds
where it was when it comes back to rest. A Bot faces choice, kept in this
browser like Appearance, can make every orb move only while busy, the same
way, or never; reduced motion still stills every face. The small-orb tests
now check that a resting face draws nothing for ten seconds with the pointer
circling it, and new tests cover each choice and reduced motion.
…y what they do

Appearance was a select whose description read the same whichever theme was
chosen. It is now a radio group drawn as a segmented control, so all three
choices are in view, and its description says what the chosen one does. Bot
faces sits beside it the same way, with Animated, Only while working and
Still, and says when the device's reduced motion is stilling faces anyway.
Arrow keys move through the choices and choose, and Tab leaves the group.
type-nav was 18px and type-body 17px: one pixel apart, which no one sees as
a step. type-nav is now 20/24, so every step of the scale is at least 2px
from the next, and a test holds that. Nothing on this branch uses type-nav
yet; keeping both names leaves the branches built on this one working.
…ead of throwing

AgentOrb threw when its size wasn't in px, so a style slip or a cast past
the type took down the whole route around it. The type still takes only px,
but at run time a size it can't read now draws the mark at its default 192px
and warns once for that size. The test renders "100%" twice and checks the
default size and the single warning.
…tops no other

A touch that turns into a scroll ends in pointercancel, not pointerup, and
nothing listened for it, so every orb kept looking at where the finger had
been. The shared pointer now lets go on a cancel. Every orb also draws in one
shared frame, and a callback that threw ended that frame and every frame
after it, stopping every orb on the page; each one is now reported and
skipped. A test for each.
Onboarding's roster drew each Bot's face, announced by name, beside a
heading with the same name, so a screen reader said every Bot twice.
AbstractAvatar takes decorative for a face beside a heading that already
names its Bot, and the roster passes it. The test checks that a decorative
face has no role or name and that the roster uses it.
The small-orb test played ten seconds of frames, then changed the orb
to thinking at the real clock, a few milliseconds after mounting. The
thinking motion had long settled by the frames that followed, so the
test passed only when a blink, timed by how many orbs the process had
drawn before, fell in that window: it failed in the full suite and on
reruns. become now changes the state at the time the frames reached.
@jerelvelarde
jerelvelarde force-pushed the jerel/openbot-bot-look branch from b821e6c to 5325fee Compare November 2, 2026 17:19
…n as on Retina

The orb draws under a transform scaled by the screen's density, and
shadowBlur is the one length a canvas transform never scales. So on a
1x screen the shadow under the eye-lights and the check was twice as
soft against them as on a Retina one. The draw now takes the density
and scales the blur by it.
A seed that starts with one of the eight Bot colours and a colon, as
moss:k2p9x7aq, is drawn in that colour; the pattern's turn, the hue
nudge and the eyes still come from the hash of the whole seed. Any other
seed, violet: and unknown colours included, draws exactly as before, and
the default team's looks are pinned so that stays true. The colours, the
parse, the spelling a seed may have and the builders for a new look live
in shared/bot-look.ts, so the server checks a look by the same rules the
orb draws it by.
PATCH /api/agents/:agentId takes an optional avatarSeed and stores it in
the existing agent_profiles.avatar_seed, so no migration. It is checked
by the shared rules the orb draws by: 1 to 64 letters, numbers, hyphens,
underscores or colons, and a colour prefix only when it is one a Bot can
have. A violet: seed or an unknown colour is a 400 with a plain sentence.
Whoever may rename the Bot may restyle it: the store's existing manage
check covers both, so a package's Bots stay as the package drew them. A
new look is named on the bot.updated audit row; an edit without one
leaves the face, and the trail, as they were.
A Bot's Setup page has a Look section beside Identity: the Bot's face,
large and alive, the eight colours as a radio group named Orange to Grey,
and New face, which keeps the colour and draws a new one. A choice saves
at once through the Bot's update, shows at once, and goes back with one
sentence if it can't be saved; the newest of several choices wins, so the
arrow keys can walk the colours while each one saves. Only people who may
edit the Bot see it.

Conversation faces, which know only a Bot's id, now draw the seed the
Bots list holds for it, so a new look reaches the sidebar, a
conversation's header and its messages, not only the Bot's own pages.
They wait for the list rather than draw a face that is about to change.
The OpenDuet skin's conversation rows, which looked seeds up themselves,
pass ids again. Two tests whose fixtures did not answer the Bots list now
do: one counted its requests, and one left a refused lookup retrying in
the app's query client for the next file.
The tenant package reference says what avatar_seed does, that it may
start with one of the eight Bot colours, and that violet: names none.
The changelog says a Bot's owner can now choose its look, how the choice
is stored, and what the API takes.
…defined key

The test now expects the Bots list request the preview row's face makes,
and its expected literal held body: undefined, a key toEqual does not
check, on the assertion-shape allow-list. It compares each request's
method and path and checks every body is absent, and its allow-list entry
goes with the fix.
The Bots list query had no ORDER BY, so Postgres returned rows in whatever
order they lay in, and an update writes a new row version: restyling or
renaming a Bot moved it, and the first Bot in Yours jumped to last.

The list is now ordered by when each Bot was made, with the id breaking a
tie, because a tenant package writes all of its Bots in one transaction and
they share a timestamp. Creation order is what the list showed before
anything was edited. Nothing in the app re-sorts it: the Bots page splits it
into sections in this order, and the app takes the first Bot as the default
when no harness is picked, so an edit could also change that default.
After the palette re-tune, sky draws cyan and coral draws red, so the
Look section's radios now say Cyan and Red instead of Light blue and
Coral. The other six already named what they draw.
The swatches drew their focus ring at half strength, about 2.4:1 on a
card. They now draw it at full strength, and the theme test that holds
every lone focus ring to 3:1 reads the Look section too.
Arrowing through the colours saved each one it passed, so every colour
was a PATCH and a bot.updated row, and restyled the Bot for everyone
while the owner was still looking. The face now previews each colour,
and the one the owner stops on saves after a short pause, when focus
leaves the row, or when the page goes away. Arrowing back to the Bot's
own colour gives back its own seed and saves nothing.

New face and a saved colour show an Undo beside New face that saves the
look from before. Undo while a colour is still in its pause takes it
back without a save. A polite live region on the row says what changed,
as "Blue, new face", "Green, saved" or "Blue, look undone".
After the palette re-tune the ten catalog Bots drew in seven colours:
three pairs shared one, and none was red. On-call Handover, which drew
green beside Interview Notes, now names red in its avatar_seed, so the
ten use all eight colours and only two are shared. The four shipped
Bots already draw yellow, red, cyan and grey, and keep their seeds.
With Bot faces set to still, the Look section's preview is a still, and
the conversation list's face is one at rest. The test picks a colour and
checks the preview is painted again in it at once, and that every other
face of the Bot draws it once it is saved. Nothing needed changing.
WebKit's Tab passes over a radio that has no tabindex of its own, so
in Safari and in the macOS desktop app's window Tab never reached the
colours. The chosen colour now carries tabindex 0 and the others -1,
so the group is still one stop and the arrow keys move through it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant