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/963-local-chief-color.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your own Bot keeps its color after a friend with the same name is added. The @ mention, the group icon, and that Bot's messages stay on the local look; the friend's Bot stays on its own ([#963](https://github.com/ThinkFlowLab/FlyBot/issues/963)).
1,876 changes: 965 additions & 911 deletions core/client.js

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { inkText } from './inks.js'
import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js'
import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js'
import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js'
import { botByName, rosterBot } from './sources.js'
import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js'
import { SecretToolCell } from './secret-card.js'
import { dateLocale, t } from './i18n.js'
Expand Down Expand Up @@ -859,7 +860,9 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
const room = roster.roomsById[sessionId]
return h('div', { className: 'bt-group-replies' },
replies.map((reply, index) => {
const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' }
const stored = roster.byId[reply.botId]
const resolved = reply.remote ? stored : (rosterBot(roster, reply.botId) ?? stored)
const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' }
if (reply.kind === 'user') {
const who = reply.name && reply.name !== 'User' ? reply.name : reply.authorId
// Another person's own line: their name and person mark beside a received bubble,
Expand Down Expand Up @@ -1034,7 +1037,7 @@ export function ToolCell({ node, sessionId, useRoster, useUi, actions }) {
const root = node.data.root
const turn = node.location?.turn?.turn ?? root.turn
const hidden = isHiddenTurn(roster, sessionId, turn)
const findBot = nameOrId => roster.byId[nameOrId] ?? roster.bots.find(bot => bot.name.toLowerCase() === String(nameOrId ?? '').toLowerCase())
const findBot = nameOrId => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId)
const calls = hidden ? [] : callTree(root)
const drawn = calls.filter(call => DRAWN.has(toolName(call)))
// The newest call still running names the work, so a program reads as the tool it is in.
Expand Down
12 changes: 12 additions & 0 deletions core/src/client/cluster-order.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Paint order for a group icon. RoomAvatar draws later seats on top, so a
// friend's look must not cover a local Bot (#963). With no remote seat the
// list stays as roomAvatarPeople returned it, including [member, you].
export function clusterOrder(people) {
const list = Array.isArray(people) ? people : []
const remote = bot => Boolean(bot?.remote)
if (!list.some(remote)) return list
const locals = list.filter(bot => !remote(bot))
const remotes = list.filter(remote)
if (list.length <= 4) return [...remotes, ...locals]
return [...locals, ...remotes]
}
20 changes: 5 additions & 15 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js'
import { call } from './api.js'
import { isHex, inkText, paintCss } from './inks.js'
import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js'
import { isHex } from './inks.js'
import { SHAPES, markupCache } from './characters.js'
import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js'
import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js'
import { MENTION_ORIGIN, currentPart, landingPart, setPluginTools } from './text.js'
import { currentPart, landingPart, setPluginTools } from './text.js'
import { mentionCss } from './mention-style.js'
import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js'
import { installBrandChrome, installTabTitle } from './brand.js'
import { lookOf } from './mark.js'
import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, ModelsSettingsStep, MODELS_STEP_ID, MODELS_STEP_ORDER, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js'
import { HeaderPill } from './header.js'
import { installHeaderEffects } from './header-effects.js'
Expand Down Expand Up @@ -290,17 +290,7 @@ export function apply(ctx) {
const style = installStyles('', 'mentions')
let last = ''
const render = () => {
const next = roster.getSnapshot().bots.map((bot) => {
const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`
const chip = /\s/.test(bot.name) ? '' : `[class*="_userRow"] [data-ref-chip="file"][title="@${bot.name.replace(/["\\]/g, '\\$&')}" i]`
const look = lookOf(bot)
const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`
const mark = look.image
? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}`
: `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`
const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ''
return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`
}).join('\n')
const next = mentionCss(roster.getSnapshot().bots)
if (next !== last) style.textContent = last = next
}
render()
Expand Down
15 changes: 9 additions & 6 deletions core/src/client/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createElement as h, useEffect, useRef, useState } from 'react'
import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js'
import { inkFill } from './inks.js'
import { t } from './i18n.js'
import { clusterOrder } from './cluster-order.js'
import { rosterBot } from './sources.js'

export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js'

Expand Down Expand Up @@ -159,17 +161,18 @@ function clusterSeats(count, frame) {
}

export function roomAvatarPeople(room, roster) {
const members = (room?.members ?? []).map(id => roster?.byId?.[id]).filter(Boolean)
const members = (room?.members ?? []).map(id => rosterBot(roster, id)).filter(Boolean)
const seen = new Set(members.map(bot => bot.id))
const remotes = []
for (const p of room?.participants ?? []) {
if (p.leftSeq !== undefined) continue
if (p.kind === 'human') continue
if (!p.remote && p.id && seen.has(p.id)) continue
if (!p.remote && p.id && roster?.byId?.[p.id]) {
if (!seen.has(p.id)) {
members.push(roster.byId[p.id])
seen.add(p.id)
const known = !p.remote && p.id ? rosterBot(roster, p.id) : undefined
if (known) {
if (!seen.has(known.id)) {
members.push(known)
seen.add(known.id)
}
continue
}
Expand All @@ -193,7 +196,7 @@ export function roomAvatarPeople(room, roster) {
}

export function RoomAvatar({ room, roster, size = 36 }) {
const people = roomAvatarPeople(room, roster)
const people = clusterOrder(roomAvatarPeople(room, roster))
const seat = (bot, seatSize, key) => (bot
? h(BotMark, { key, bot, size: seatSize })
: h('span', { key, className: 'bt-cluster-you', style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }))
Expand Down
37 changes: 37 additions & 0 deletions core/src/client/mention-style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { lookOf, maskOf, DEFAULT_MASK } from './characters.js'
import { inkText, paintCss } from './inks.js'
import { MENTION_ORIGIN } from './text.js'

// One @Name file chip per display name. A local Bot owns the chip when a
// friend has the same name; a later local still replaces an earlier local,
// and a later friend replaces an earlier friend only when no local has it (#963).
function chipKey(bot) {
if (/\s/.test(bot?.name)) return null
return String(bot?.name ?? '').toLowerCase()
}

export function mentionCss(bots) {
const list = Array.isArray(bots) ? bots : []
const owner = new Map()
for (const bot of list) {
const key = chipKey(bot)
if (key === null) continue
const prev = owner.get(key)
if (prev && !prev.remote && bot?.remote) continue
owner.set(key, bot)
}
return list.map((bot) => {
const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`
const key = chipKey(bot)
const chip = key !== null && owner.get(key) === bot
? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, '\\$&')}" i]`
: ''
const look = lookOf(bot)
const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`
const mark = look.image
? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}`
: `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`
const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ''
return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`
}).join('\n')
}
30 changes: 29 additions & 1 deletion core/src/client/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ export function indexRoster(value) {
setLocalBots(value.bots)
setPluginTools(value.pluginTools)
const byId = {}
// A remote chat must not replace a local Bot's id or session (#963). Nobody
// takes another Bot's own id; the same Bot may refresh its own keys.
const claim = (bot, id) => {
if (id === undefined || id === null || id === '') return
const prev = byId[id]
if (prev && prev !== bot && !prev.remote && bot?.remote) return
if (prev && prev !== bot && prev.id === id) return
byId[id] = bot
}
for (const bot of value.bots) {
for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) byId[id] = bot
for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) claim(bot, id)
}
const roomsById = {}
for (const room of value.rooms) roomsById[room.id] = room
Expand All @@ -45,6 +54,25 @@ export function indexRoster(value) {
return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }
}

// A member id, session id, or part id. A local Bot wins when a friend's chat
// was indexed under the same key (#963).
export function rosterBot(roster, id) {
if (id === undefined || id === null || id === '') return undefined
const list = roster?.bots ?? []
const own = list.find(bot => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id)))
if (own) return own
return roster?.byId?.[id]
}

// The local Bot of this display name, else a friend's Bot with that name (#963).
export function botByName(bots, name) {
const wanted = String(name ?? '').toLowerCase()
if (wanted === '') return undefined
const list = Array.isArray(bots) ? bots : []
return list.find(bot => bot && !bot.remote && String(bot.name ?? '').toLowerCase() === wanted)
?? list.find(bot => bot && String(bot.name ?? '').toLowerCase() === wanted)
}

// Whether a main-view Session id belongs to the team's DSH Agent: the conversation
// group then stays withdrawn while the shell keeps the sidebar. Before the roster
// arrives, the ids cached from the last pull answer.
Expand Down
5 changes: 4 additions & 1 deletion core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ export const MENTION_ORIGIN = 'https://bots.local/'
// ("@新 Bot" in "@新 Bot 2"); it then stays plain text.
export function linkMentions(text, bots, selfId) {
if (!text || !text.includes('@')) return text
// Same-length names keep their order. Locals go first so "@Chief" names my
// Chief when a friend uses that title too (#963).
const ordered = [...(bots ?? [])].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote)))
let out = ''
let cursor = 0
for (const span of mentionSpans(text, bots)) {
for (const span of mentionSpans(text, ordered)) {
if (!span.bot || span.bot.id === selfId) continue
out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, '\\$&')}](${MENTION_ORIGIN}${span.bot.id})`
cursor = span.end
Expand Down
90 changes: 90 additions & 0 deletions core/test/local-look.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { lookOf } from '../src/client/characters.js'
import { clusterOrder } from '../src/client/cluster-order.js'
import { mentionCss } from '../src/client/mention-style.js'
import { botByName, indexRoster, rosterBot } from '../src/client/sources.js'
import { linkMentions, MENTION_ORIGIN } from '../src/client/text.js'

const local = {
id: 'chief',
name: 'Chief',
color: 'deepseek',
avatar: { shape: 'whale' },
sessionId: 'chief-now',
parts: ['chief'],
}
const friend = {
id: 'remote-1',
name: 'Chief',
color: '#ff5eb1',
avatar: { shape: 'moon' },
remote: { handle: '@chief.flybot98b8ecba.home' },
// A friend's chat indexed under the local Bot's own id and current session.
sessionId: 'chief-now',
parts: ['chief'],
}

test('a friend chat does not take a local Bot\'s id or session in the roster index (#963)', () => {
const roster = indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] })
assert.equal(roster.byId.chief, local)
assert.equal(roster.byId['chief-now'], local)
assert.equal(roster.byId['remote-1'], friend)
assert.equal(lookOf(roster.byId.chief).color, 'deepseek')
assert.equal(lookOf(roster.byId.chief).shape, 'whale')
assert.equal(lookOf(roster.byId['remote-1']).color, '#ff5eb1')
// The friend's chat is listed first: the local Bot still owns its keys.
const flipped = indexRoster({ bots: [friend, local], rooms: [] })
assert.equal(flipped.byId.chief, local)
assert.equal(flipped.byId['chief-now'], local)
})

test('a member id resolves to the local Bot when a friend shares that key (#963)', () => {
const roster = indexRoster({ bots: [local, friend], rooms: [] })
assert.equal(rosterBot(roster, 'chief').color, 'deepseek')
assert.equal(rosterBot(roster, 'chief-now').avatar.shape, 'whale')
assert.equal(rosterBot(roster, 'remote-1').id, 'remote-1')
assert.equal(botByName(roster.bots, 'Chief').id, 'chief')
assert.equal(botByName([friend, local], 'Chief').id, 'chief')
assert.equal(botByName([friend], 'Chief').id, 'remote-1')
})

test('an @Name chip keeps the local Bot\'s look when a friend has the same name (#963)', () => {
const css = mentionCss([local, friend])
const chip = css.split('\n').filter(line => line.includes('title="@Chief"'))
assert.ok(chip.length > 0)
assert.ok(chip.every(line => line.includes('var(--bt-ink-deepseek)') || line.includes('whale') || !line.includes('#ff5eb1')))
assert.equal(chip.some(line => line.includes('#ff5eb1')), false)
assert.equal(chip.some(line => line.includes('var(--bt-ink-deepseek)')), true)
// The friend's own mention anchor stays the friend's color.
assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}remote-1"\\]\\{color:#ff5eb1`))
assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}chief"\\]\\{color:var\\(--bt-ink-deepseek\\)`))
// A name only a friend has still wears that friend's look.
const only = mentionCss([{ id: 'h', name: 'Helper', color: '#ff5eb1', remote: { handle: '@helper.other.home' } }])
assert.match(only, /title="@Helper"/)
assert.match(only, /#ff5eb1/)
// Friend listed first: the local Bot still owns the shared chip.
const flipped = mentionCss([friend, local]).split('\n').filter(line => line.includes('title="@Chief"'))
assert.equal(flipped.some(line => line.includes('#ff5eb1')), false)
})

test('@Chief in a bubble names the local Bot when a friend is listed first (#963)', () => {
const linked = linkMentions('@Chief hello', [friend, local], 'someone-else')
assert.equal(linked, `[Chief](${MENTION_ORIGIN}chief) hello`)
})

test('a friend seat does not cover the local Bot in a group icon (#963)', () => {
const you = null
assert.deepEqual(clusterOrder([local, you]).map(bot => bot?.id ?? 'you'), ['chief', 'you'])
assert.deepEqual(clusterOrder([local, friend]).map(bot => bot.id), ['remote-1', 'chief'])
const many = clusterOrder([local, { id: 'b', name: 'B' }, friend, { id: 'r2', name: 'C', remote: { handle: '@c' } }, { id: 'r3', name: 'D', remote: { handle: '@d' } }])
assert.deepEqual(many.map(bot => bot.id), ['chief', 'b', 'remote-1', 'r2', 'r3'])
const mark = readFileSync(new URL('../src/client/mark.js', import.meta.url), 'utf8')
assert.match(mark, /clusterOrder\(roomAvatarPeople/)
const client = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
assert.match(client, /mentionCss\(/)
const cells = readFileSync(new URL('../src/client/cells.js', import.meta.url), 'utf8')
assert.match(cells, /botByName\(roster\.bots/)
assert.match(cells, /rosterBot\(roster, reply\.botId\)/)
})
Loading
Loading