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
40 changes: 37 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Generated from src/client by `npm run build` (scripts/build-client.mjs). Do not edit by hand.
window.__ModuleLoader__.load({
id: 'ds-bot',
Expand Down Expand Up @@ -12018,6 +12018,37 @@
["everyone", "Everyone", "Every member answers in turn, admin first."]
];
var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
function faceSeatBot(seat, roster) {
if (!seat) return void 0;
const record = seat.record && typeof seat.record === "object" ? seat.record : {};
const extra = {};
if (typeof record.color === "string" && record.color) extra.color = record.color;
if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar;
if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local;
const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : "";
return lookBotForRemote({
handle,
displayName: record.displayName || seat.bot,
name: seat.bot,
label: record.label,
id: record.id || seat.key,
...extra
}, roster);
}
function faceMemberLook(bot) {
if (!bot) return void 0;
return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot);
}
function faceMarks(person, roster, size) {
const marks = [];
for (const seat of person?.seats ?? []) {
const bot = faceSeatBot(seat, roster);
if (!bot) continue;
marks.push((0, import_react26.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
}
if (marks.length === 0) return [(0, import_react26.createElement)(PersonMark, { key: "person", size, name: person?.name })];
return marks;
}
function faceRoomCount(room2, roster) {
const people2 = facePeople(room2, roster).length;
const seats = faceSeats(room2, roster).length;
Expand All @@ -12027,7 +12058,7 @@
}
var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
const bots = person.seats.flatMap((seat, index) => {
const name = seat.local && onOpenBot ? (0, import_react26.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react26.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
const marked = isLeadSeat(seat, lead) ? [name, (0, import_react26.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
Expand All @@ -12039,7 +12070,7 @@
(0, import_react26.createElement)(
"span",
{ className: "bt-gs-who bt-face-person" },
(0, import_react26.createElement)(PersonMark, { size, name: person.name }),
(0, import_react26.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
(0, import_react26.createElement)(
"span",
{ className: "bt-face-person-text" },
Expand Down Expand Up @@ -12209,7 +12240,7 @@
face ? (0, import_react26.createElement)(
Card,
{ title: t("Members · {count}", { count: people2.length }) },
people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
) : null,
face ? null : (0, import_react26.createElement)(
Card,
Expand Down Expand Up @@ -12406,6 +12437,8 @@
.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
.bt-face-marks>.bt-mark{flex:none}
.bt-face-person{cursor:default}
.bt-face-person:hover .bt-row-title{text-decoration:none}
.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
Expand Down Expand Up @@ -15747,6 +15780,7 @@
(0, import_react38.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react38.createElement)(FacePersonRow, {
key: person.key,
person,
roster,
className: "bt-member",
onOpenBot: (id) => actions.openSession(id),
lead: room2.face?.lead?.handle
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ function FaceMembers({ room, roster, actions }) {
h(FaceLead, { room, roster, pick }),
h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: people.length })),
h('div', { className: 'bt-members', role: 'list' }, people.map(person => h(FacePersonRow, {
key: person.key, person, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle,
key: person.key, person, roster, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle,
}))))
}

Expand Down
53 changes: 49 additions & 4 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createElement as h, useEffect, useState } from 'react'
import { isMainOf } from './sources.js'
import { PlusIcon, ChevronRightIcon } from './icons.js'
import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookBotForRemote } from './mark.js'
import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookAvoidingLocals, lookBotForRemote, lookKey, lookOf } from './mark.js'
import { setLocalBots } from './characters.js'
import { Card, Row, InlineText, SwitchRow } from './bot-settings.js'
import { Segmented } from './motion.js'
import { FolderMenu } from './folder-menu.js'
Expand All @@ -22,6 +23,48 @@ export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode
import { faceSeats, faceSeatLabel, facePeople, facePersonLabel } from './face-seats.js'

export { faceSeats, faceSeatLabel, facePeople }
export { setLocalBots, lookKey }

// The Bot a member row draws. A local seat is the roster record the sidebar uses. A friend's
// seat is the chat record, with the face-room look winning, the same way a transcript mark is
// chosen. Two homes named SelfA… and SelfB… share an initial; the row must not.
export function faceSeatBot(seat, roster) {
if (!seat) return undefined
const record = seat.record && typeof seat.record === 'object' ? seat.record : {}
const extra = {}
if (typeof record.color === 'string' && record.color) extra.color = record.color
if (record.avatar && typeof record.avatar === 'object') extra.avatar = record.avatar
if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local
const handle = typeof record.handle === 'string' && record.handle.trim()
? record.handle
: (typeof seat.key === 'string' && seat.key.startsWith('@') ? seat.key : '')
return lookBotForRemote({
handle,
displayName: record.displayName || seat.bot,
name: seat.bot,
label: record.label,
id: record.id || seat.key,
...extra,
}, roster)
}

// The picture BotMark paints for that Bot: a friend's look steps off a same-named local Bot.
export function faceMemberLook(bot) {
if (!bot) return undefined
return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot)
}

function faceMarks(person, roster, size) {
const marks = []
for (const seat of person?.seats ?? []) {
const bot = faceSeatBot(seat, roster)
if (!bot) continue
marks.push(h(BotMark, { key: seat.key, bot, size, live: false }))
}
// No seat to draw is the silhouette, never an empty disc (#1089).
if (marks.length === 0) return [h(PersonMark, { key: 'person', size, name: person?.name })]
return marks
}

// A face room says what it counts, people and Bots, not an ambiguous "2 members" (#306).
export function faceRoomCount(room, roster) {
Expand All @@ -38,7 +81,7 @@ export const isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(s

// One row per person (#457): "Ann (you) · Chief, Researcher", my own Bots each opening its chat.
// `lead`: the room lead's handle (#476), marked on its Bot.
export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) {
export function FacePersonRow({ person, onOpenBot, lead, roster, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) {
const bots = person.seats.flatMap((seat, index) => {
const name = seat.local && onOpenBot
? h('button', { key: seat.key, type: 'button', className: 'bt-face-bot', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot)
Expand All @@ -48,7 +91,7 @@ export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row
})
return h('div', { className, role: 'listitem', 'data-flybot': 'face-person', 'data-mine': person.mine || undefined },
h('span', { className: 'bt-gs-who bt-face-person' },
h(PersonMark, { size, name: person.name }),
h('span', { className: 'bt-face-marks', 'data-flybot': 'face-person-avatars' }, faceMarks(person, roster, size)),
h('span', { className: 'bt-face-person-text' },
h('span', { className: 'bt-row-title' }, facePersonLabel(person)),
h('span', { className: 'bt-face-bots' }, bots))))
Expand Down Expand Up @@ -168,7 +211,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))),
h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))),
face ? h(Card, { title: t('Members · {count}', { count: people.length }) },
people.map(person => h(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null,
people.map(person => h(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null,
face ? null : h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) },
humans.map((person) => h('div', { key: person.id, className: 'bt-set-row bt-bs-row bt-gs-member' },
h('span', { className: 'bt-gs-who' },
Expand Down Expand Up @@ -295,6 +338,8 @@ export const GROUP_SETTINGS_CSS = `
.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
.bt-face-marks>.bt-mark{flex:none}
.bt-face-person{cursor:default}
.bt-face-person:hover .bt-row-title{text-decoration:none}
.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
Expand Down
143 changes: 143 additions & 0 deletions core/test/face-member-avatar.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

// #1104: room details drew one pink S for SelfA1025 and SelfB1025. The chat already
// tells those Chiefs apart (local whale, the friend's same whale stepped aside).

async function load(entry) {
const bundle = await build({
entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
const module = { exports: {} }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports, setTimeout, clearTimeout,
require: (name) => {
const platform = {
react: {
Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment',
},
'react-dom': { createPortal: node => node },
'@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text },
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
})
return module.exports
}

const expand = (node, names) => {
if (Array.isArray(node)) return node.flatMap(child => expand(child, names))
if (node === null || typeof node !== 'object') return node
if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names)
return { ...node, children: (node.children ?? []).map(child => expand(child, names)) }
}
const find = (node, match) => {
if (Array.isArray(node)) return node.flatMap(child => find(child, match))
if (node === null || typeof node !== 'object') return []
return [...(match(node) ? [node] : []), ...find(node.children, match)]
}

const settings = await load('group-settings.js')
const { FacePersonRow } = settings
const { facePeople } = await load('face-seats.js')
const { personInitial } = await load('mark.js')

const HANDLE = '@chief.selfa1025.home'
const chiefB = { id: 'b1', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } }
const chiefA = {
id: 'remote-a', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' },
remote: { handle: HANDLE, homeName: 'SelfA1025', owner: 'SelfA1025' },
}
const seatB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' }
const seatA = {
kind: 'bot', remote: true, id: HANDLE, handle: HANDLE, displayName: 'Chief', ownerName: 'SelfA1025',
color: '#4d6bfe', avatar: { shape: 'whale' },
}

function roster(participants = [seatB, seatA], bots = [chiefB, chiefA]) {
return {
self: { displayName: 'SelfB1025' },
bots,
byId: { b1: chiefB },
roomsById: { r: { id: 'r', origin: 'face-to-face', name: '739184', members: ['b1'], participants } },
}
}

function rowsOf(view) {
settings.setLocalBots?.(view.bots)
const people = facePeople(view.roomsById.r, view)
return people.map(person => expand(FacePersonRow({ person, roster: view }), ['BotMark', 'PersonMark']))
}
const shapesOf = row => find(row, n => typeof n.props?.['data-shape'] === 'string').map(n => n.props['data-shape'])

test('#1104: SelfA1025 and SelfB1025 share an initial, so a letter mark cannot tell the homes apart', () => {
const view = roster()
const people = facePeople(view.roomsById.r, view)
// Copy out of the bundled realm; a vm array is not reference-equal to a host array.
assert.deepEqual(JSON.parse(JSON.stringify(people.map(person => person.name))), ['SelfB1025', 'SelfA1025'])
assert.equal(personInitial(people[0].name), 'S')
assert.equal(personInitial(people[1].name), 'S')
})

test('#1104: member rows draw each home\'s chat look, not the same initial', () => {
const drawn = rowsOf(roster())
const shapes = drawn.map(shapesOf)
assert.deepEqual(shapes[0], ['whale'], 'my Chief stays the whale the sidebar and the transcript draw')
assert.equal(shapes[1].length, 1)
assert.notEqual(shapes[1][0], 'whale', 'the friend\'s same whale is stepped, as the chat mark is')
assert.equal(find(drawn, n => n.props?.['data-initial'] === 'S').length, 0)
for (const row of drawn) {
const mark = find(row, n => typeof n.props?.['data-shape'] === 'string')[0]
assert.ok(mark)
assert.equal(find(mark, n => n.type === 'svg').length, 1, 'a character, not a blank disc')
}
if (typeof settings.faceMemberLook === 'function' && typeof settings.faceSeatBot === 'function' && typeof settings.lookKey === 'function') {
const view = roster()
settings.setLocalBots(view.bots)
const people = facePeople(view.roomsById.r, view)
const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view))))
assert.match(keys[0], /^shape:whale:/)
assert.notEqual(keys[0], keys[1])
assert.deepEqual(shapes, people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape]))
}
})

test('#1104: a seat with no stored look still draws a seeded character (#1089)', () => {
const bareA = { kind: 'bot', remote: true, id: '@chief.selfa1025.home', handle: '@chief.selfa1025.home', displayName: 'Chief', ownerName: 'SelfA1025' }
const bareB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' }
const plain = { id: 'b1', name: 'Chief' }
const drawn = rowsOf({
self: { displayName: 'SelfB1025' },
bots: [plain],
byId: { b1: plain },
roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } },
})
const shapes = drawn.map(shapesOf)
assert.equal(shapes.length, 2)
for (const shape of shapes) {
assert.equal(shape.length, 1)
assert.match(shape[0], /^[a-z]/)
}
assert.equal(find(drawn, n => n.props?.['data-initial']).length, 0)
const view = {
self: { displayName: 'SelfB1025' },
bots: [plain],
byId: { b1: plain },
roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } },
}
settings.setLocalBots(view.bots)
const people = facePeople(view.roomsById.r, view)
const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view))))
assert.equal(keys.length, 2)
assert.notEqual(keys[0], keys[1], 'a seeded shape or color still tells the homes apart')
const looked = people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape])
assert.equal(JSON.stringify(shapes), JSON.stringify(looked))
})
7 changes: 4 additions & 3 deletions core/test/face-person-first.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,14 @@ test('one entry per person, mine first, "(you)" only on the person, count = peop
assert.equal(roomSubtitle(roster.roomsById.r, roster), '2 people · 3 Bots')

const opened = []
const row = expand(FacePersonRow({ person: people[1], onOpenBot: id => opened.push(id) }), ['PersonMark'])
const row = expand(FacePersonRow({ person: people[1], roster, onOpenBot: id => opened.push(id) }), ['BotMark'])
assert.equal(row.props['data-flybot'], 'face-person')
assert.equal(find(row, n => String(n.props?.className ?? '').split(' ').includes('bt-person-mark')).length, 1, 'a person mark, not a Bot avatar')
assert.equal(find(row, n => typeof n.props?.['data-shape'] === 'string').length, 2, 'each Bot the person brought, not one shared initial')
assert.equal(find(row, n => n.props?.['data-initial']).length, 0)
const words = node => text(find(node, n => n.props?.className === 'bt-face-person-text')[0])
assert.match(words(row), /^SelfA0056Chief, Researcher$/)
assert.equal(find(row, n => n.type === 'button').length, 0, "another person's Bots are not my chats")
const mine = expand(FacePersonRow({ person: people[0], onOpenBot: id => opened.push(id) }), ['PersonMark'])
const mine = expand(FacePersonRow({ person: people[0], roster, onOpenBot: id => opened.push(id) }), ['BotMark'])
assert.equal(mine.props['data-mine'], true)
assert.match(words(mine), /^SelfB0056 \(you\)Chief$/)
find(mine, n => n.type === 'button')[0].props.onClick()
Expand Down
Loading
Loading