Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changelog.d/1069-friend-chief-avatar.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's Bot that shares a local Bot's display name no longer wears that local Bot's picture: two default Chiefs both store the blue whale, and the friend's row now steps to a different shape while the local Chief keeps its own look ([#1069](https://github.com/ThinkFlowLab/FlyBot/issues/1069)).
12 changes: 11 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1906,12 +1906,22 @@ var localBots = [];
function setLocalBots(bots) {
localBots = (Array.isArray(bots) ? bots : []).filter((item) => item && !item.remote);
}
var displayName = (bot) => String(bot?.name ?? "").trim().toLowerCase();
var sameDrawnLook = (a, b) => {
const keys = lookKeys(a);
return lookKeys(b).some((key) => keys.includes(key));
};
function lookAvoidingLocals(bot, locals = localBots) {
const look = lookOf(bot);
const avatar = bot?.avatar ?? {};
if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji || hasShape(avatar.shape)) return look;
if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji) return look;
const own = (Array.isArray(locals) ? locals : []).filter((item) => item && !item.remote);
if (own.length === 0) return look;
if (hasShape(avatar.shape)) {
const name = displayName(bot);
const named = name === "" ? [] : own.filter((item) => displayName(item) === name);
if (!named.some((item) => sameDrawnLook(look, lookOf(item)))) return look;
}
const taken = new Set(own.flatMap((item) => lookKeys(lookOf(item))));
const free = (candidate) => !lookKeys(candidate).some((key) => taken.has(key));
if (free(look)) return look;
Expand Down
20 changes: 17 additions & 3 deletions core/src/client/characters.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion core/src/client/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ function useGaze(ref, enabled) {
// of animations.
const FIDGETS = ['hop', 'sway', 'stretch', 'look']
export function BotMark({ bot, look: given, size = 36, state = 'idle', live = false, gaze = false, pokeable = false, label }) {
// A friend's Bot never shares the exact fallback look of one of my own Bots (#384).
// A friend's Bot never shares the exact fallback look of one of my own Bots (#384),
// and a published preset steps off a local Bot of the same display name (#1069).
const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot))
const ref = useRef(null)
const maskId = useRef('')
Expand Down
37 changes: 34 additions & 3 deletions core/test/looks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se

test('a friend\'s Bot fallback look never matches one of my own Bots exactly (#384)', () => {
const mine = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } }
// Force a remote seed that would otherwise collide: same shape+color as mine via stored fields cleared.
const friend = { id: 'remote-1', name: 'Chief', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } }
// Stored shape is the friend's real look — leave it (sync already happened).
// A stored shape is the friend's real look. A different display name keeps it, even when
// the picture matches a local Bot (#384). The same name steps off it (#1069).
const friend = { id: 'remote-1', name: 'Scout', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } }
assert.deepEqual(lookAvoidingLocals(friend, [mine]), lookOf(friend))
// Seeded fallback that collides: no avatar.shape, color matches MAIN whale via explicit color + lucky seed.
// Build a remote whose lookOf equals mine by copying shape/color through seed collision is hard;
Expand All @@ -103,6 +103,37 @@ test('a friend\'s Bot fallback look never matches one of my own Bots exactly (#3
assert.equal(lookAvoidingLocals(pictured, [twin]).image, 'https://example.test/a.png')
})

test('a friend Bot that shares a display name does not wear that local Bot\'s look (#1069)', () => {
const local = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } }
const localLook = lookOf(local)
// Bot Social publishes the other home's default Chief as the same whale, color as lowercase hex.
const friend = { id: 'remote-1', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@chief.selfa1025.home' } }
const drawn = lookAvoidingLocals(friend, [local])
assert.deepEqual(lookOf(local), localLook, 'the local Bot keeps its stored look')
assert.deepEqual(friend.avatar, { shape: 'whale' }, 'the friend record is not rewritten')
assert.ok(!lookKeys(drawn).some(key => lookKeys(localLook).includes(key)), 'same display name must not share the drawn look')
assert.notEqual(drawn.shape, 'whale')
assert.equal(drawn.color, '#4d6bfe', 'shape steps first; the published color stays')
// Spacing and case are the same name.
const cased = lookAvoidingLocals({ ...friend, name: ' chief ' }, [local])
assert.ok(!lookKeys(cased).some(key => lookKeys(localLook).includes(key)))
// A different name keeps the published look, even when the picture matches a local Bot.
const helper = { id: 'remote-2', name: 'Helper', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@helper.selfa1025.home' } }
assert.deepEqual(lookAvoidingLocals(helper, [local]), lookOf(helper))
// A same-named friend with its own look stays on that look.
const pink = { id: 'remote-3', name: 'Chief', color: '#ff5eb1', avatar: { shape: 'moon' }, remote: { handle: '@chief.other.home' } }
assert.deepEqual(lookAvoidingLocals(pink, [local]), lookOf(pink))
// An image or emoji is the friend's real mark.
const photo = { ...friend, avatar: { image: 'https://example.test/chief.png', shape: 'whale' } }
assert.equal(lookAvoidingLocals(photo, [local]).image, 'https://example.test/chief.png')
const emoji = { ...friend, avatar: { emoji: '🐳', shape: 'whale' } }
assert.equal(lookAvoidingLocals(emoji, [local]).emoji, '🐳')
// The roster index is what the sidebar draws from: no explicit locals argument.
indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] })
assert.ok(!lookKeys(lookAvoidingLocals(friend)).some(key => lookKeys(localLook).includes(key)))
indexRoster({ bots: [], rooms: [] })
})

test('fallback avoidance treats a named ink and its published hex as one color, and survives a full shape set (#384)', () => {
const seeded = { id: 'r3', name: 'Scout', remote: { handle: '@scout.other.home' } }
const seededLook = lookOf(seeded)
Expand Down
4 changes: 2 additions & 2 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- A friend's remote Bot is stored and delivered by its handle (`@<bot>.<home>`, what `sendUserMentions` matches), and the user reads one name for it on every surface (#967): `remoteMentionLabel` in `shared/remote-label.js` — the sidebar row's title (`friendBotTitle`, #498), and on a title two different handles share the home too, so two friends' `Chief` stay apart. The `@` menu inserts that name (`remoteMentionRows` in `flybot-registry.js`, so a row shows what it inserts), the bubble chips it (`mentionableBots` / `userTextParts` in `src/client/user-message.js`), and the sidebar preview renders it (`nameMentions` in `src/client/text.js`). `mentionableBots` reads `roster.remoteBots` as well as `roster.bots` and the room's seats, so a friend's Bot chips before it has a chat of its own and inside a group it holds no seat in; it also keeps the handle as a second name of the same Bot, so a line written before this change still chips, and a chip whose Bot has no chat yet is a mark, not a button that would open nothing. Names come from the sanitized roster records, never from message text, so a remote-controlled name reaches the UI only through them (#71; display names, #9). The handle stays the routing key: the menu's text is a label, not a transport token.
- In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305).
- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137).
- A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look.
- A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069).
- A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`.
- A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`).
- The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged.
Expand All @@ -115,7 +115,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second

### One sidebar list

- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (`<group> · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`.
- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. A published preset shape stays, unless it is the same drawn look as a local Bot with the same display name — two default Chiefs both store the blue whale — in which case the friend's row steps and the local Bot keeps its stored look (#1069). A published image or emoji stays. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (`<group> · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`.

### Agent inbox (#349, step 1)

Expand Down
Loading
Loading