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/305-face-at-picker.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a Face-to-face room the composer's `@` lists each Bot in the room once, named like the Members panel (`SelfA · Chief`), with no friend's Bot from outside the room, no separate Friends' Bots section and no wrong "1 members" count ([#305](https://github.com/ThinkFlowLab/FlyBot/issues/305)).
51 changes: 41 additions & 10 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ var ZH = {
"Admin leads": "管理员主持",
"Plain messages go to the admin, who calls others with @Name.": "普通消息先给管理员,由管理员用 @名字 点名其他成员。",
Everyone: "所有人",
"Every Bot in this room": "房间里的所有 Bot",
"Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。",
"Members · {count}": "成员 · {count}",
"{name} (you)": "{name}(你)",
Expand Down Expand Up @@ -7226,20 +7227,48 @@ var conversationOf = (roster, sessionId) => {
function mentionRows(roster, sessionId, query = "") {
const here = conversationOf(roster, sessionId);
if (here === null) return null;
const face = isFaceRoom(here.room);
const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0);
const bots = here.room ? [
...here.room.members.map((id) => roster.byId[id]).filter(Boolean),
...(here.room.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
...(here.room.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, handle: p.handle }))
...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot),
...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
// A remote seat already listed under its handle (a duplicate participant record) is offered once.
...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
id: p.handle || p.id,
name: remoteBotTitle(p),
kind: "bot",
remote: p,
handle: p.handle,
...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot"), false) } : {}
}))
] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
const needle = query.trim().toLowerCase();
const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` }));
const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` }));
const everyone = t("Everyone");
if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) {
rows.push({ room: here.room, text: `@${everyone} ` });
}
rows.push(...remoteMentionRows(roster, query));
if (!face) rows.push(...remoteMentionRows(roster, query));
return rows;
}
var isFaceRoom = (room2) => room2?.origin === "face-to-face";
var myOwnerName = (roster, live, bot) => {
const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle));
return cleanPeerName(seat?.ownerName || roster.self?.displayName, "");
};
var uniqueBy = (list, key) => {
const seen = /* @__PURE__ */ new Set();
return list.filter((item) => {
const k = key(item);
if (!k || seen.has(k)) return !k;
seen.add(k);
return true;
});
};
var faceLabel = (person, bot, mine) => {
if (!person) return bot;
return `${mine ? t("{name} (you)", { name: person }) : person} · ${bot}`;
};
function chatRows(roster, list, sessionId) {
const here = conversationOf(roster, sessionId);
if (here === null) return [];
Expand Down Expand Up @@ -7269,17 +7298,19 @@ function atSources({ roster, sessions, launched, iconFor }) {
showGroupTitle: false,
async candidates(session, { query }) {
const snapshot = roster.getSnapshot();
const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"),
...row.bot?.role ? { description: row.bot.role } : {},
name: row.bot ? row.bot.label ?? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t("Everyone"),
...row.bot?.role && !face ? { description: row.bot.role } : {},
...row.remote ? { description: remoteBotTitle(row.remote) } : {},
...row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {},
...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {},
...row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {},
// A face room's local member list holds only this home's Bots, so a count would be wrong (#305).
...row.room ? { description: face ? t("Every Bot in this room") : t("{count} members", { count: row.room.members.length }) } : {},
icon: iconFor(row),
section: t("Bots"),
value: row.text
}));
if (mentionRows(snapshot, session.sessionId, "") === null) return team;
if (mentionRows(snapshot, session.sessionId, "") === null || face) return team;
const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({
name: row.name,
...row.description ? { description: row.description } : {},
Expand Down
64 changes: 50 additions & 14 deletions core/src/client/at-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Both are sources of the shell's trigger menu; the shell's own file and session
// rows stay beside them, because a source cannot be hidden for one Session.
import { mentionCandidates, remoteMentionRows } from './flybot-registry.js'
import { remoteBotTitle, listedChatBots } from '../shared/remote-label.js'
import { cleanPeerName, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js'
import { t } from './i18n.js'
import { currentPart, relativeShort } from './text.js'

Expand All @@ -27,26 +27,59 @@ const conversationOf = (roster, sessionId) => {
export function mentionRows(roster, sessionId, query = '') {
const here = conversationOf(roster, sessionId)
if (here === null) return null
const face = isFaceRoom(here.room)
const live = (here.room?.participants ?? []).filter(p => p.leftSeq === undefined)
const bots = here.room
? [
...here.room.members.map(id => roster.byId[id]).filter(Boolean),
...(here.room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined)
...here.room.members.map(id => roster.byId[id]).filter(Boolean)
.map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot)),
...live.filter(p => p.kind === 'human')
.map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })),
...(here.room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined)
.map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle })),
// A remote seat already listed under its handle (a duplicate participant record) is offered once.
...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id)
.map(p => ({
id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle,
...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot'), false) } : {}),
})),
]
: listedChatBots(roster).filter(bot => bot.id !== here.self.id)
const needle = query.trim().toLowerCase()
const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` }))
const rows = bots
.filter(bot => bot.name.toLowerCase().includes(needle) || String(bot.label ?? '').toLowerCase().includes(needle))
.map(bot => ({ bot, text: `@${bot.name} ` }))
const everyone = t('Everyone')
if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some(word => word.toLowerCase().startsWith(needle))) {
rows.push({ room: here.room, text: `@${everyone} ` })
}
// Friends' remote Bots are members of every chat, marked remote.
rows.push(...remoteMentionRows(roster, query))
// Friends' remote Bots are members of every chat, marked remote. Not of a face-to-face
// room: only the Bots in that room can be reached there, each listed once above (#305).
if (!face) rows.push(...remoteMentionRows(roster, query))
return rows
}

const isFaceRoom = room => room?.origin === 'face-to-face'
// As `faceSeats` names it: the owner name Bot Social stamped on my Bot's seat, else my own name.
const myOwnerName = (roster, live, bot) => {
const seat = live.find(p => !p.remote && p.kind !== 'human' && (p.id === bot.id || (bot.handle && p.handle === bot.handle)))
return cleanPeerName(seat?.ownerName || roster.self?.displayName, '')
}
const uniqueBy = (list, key) => {
const seen = new Set()
return list.filter((item) => {
const k = key(item)
if (!k || seen.has(k)) return !k
seen.add(k)
return true
})
}
// In a face-to-face room a Bot is named as the Members panel names it: "Ann · Chief",
// "Me (you) · Chief" (#229, #305). Only the label changes; the inserted "@Name" stays the
// one the room routes on.
const faceLabel = (person, bot, mine) => {
if (!person) return bot
return `${mine ? t('{name} (you)', { name: person }) : person} · ${bot}`
}

// The chats a message here can reference: every other Bot's chat, then the latest
// conversations outside the team. Group chats stay out: a group's Session holds only
// the user's messages, so a snapshot of it would miss every Bot's reply.
Expand Down Expand Up @@ -89,18 +122,21 @@ export function atSources({ roster, sessions, launched, iconFor }) {
showGroupTitle: false,
async candidates(session, { query }) {
const snapshot = roster.getSnapshot()
const face = isFaceRoom(snapshot.roomsById?.[session.sessionId])
const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({
name: row.bot ? row.bot.name : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'),
...(row.bot?.role ? { description: row.bot.role } : {}),
name: row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? `${row.remote.name} (${row.remote.handle})` : t('Everyone'),
...(row.bot?.role && !face ? { description: row.bot.role } : {}),
...(row.remote ? { description: remoteBotTitle(row.remote) } : {}),
...(row.bot?.remote ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}),
...(row.room ? { description: t('{count} members', { count: row.room.members.length }) } : {}),
...(row.bot?.remote && !face ? { description: remoteBotTitle(row.bot.remote === true ? row.bot : row.bot.remote) } : {}),
// A face room's local member list holds only this home's Bots, so a count would be wrong (#305).
...(row.room ? { description: face ? t('Every Bot in this room') : t('{count} members', { count: row.room.members.length }) } : {}),
icon: iconFor(row),
section: t('Bots'),
value: row.text,
}))
// FlyBot plugins' rows (remote Bots...), only where the team's menu shows.
if (mentionRows(snapshot, session.sessionId, '') === null) return team
// FlyBot plugins' rows (remote Bots...), only where the team's menu shows, and not in a
// face-to-face room, whose Bots are all listed above (no separate Friends' Bots, #305).
if (mentionRows(snapshot, session.sessionId, '') === null || face) return team
const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map(row => ({
name: row.name,
...(row.description ? { description: row.description } : {}),
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ const ZH = {
'Admin leads': '管理员主持',
'Plain messages go to the admin, who calls others with @Name.': '普通消息先给管理员,由管理员用 @名字 点名其他成员。',
Everyone: '所有人',
'Every Bot in this room': '房间里的所有 Bot',
'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。',
'Members · {count}': '成员 · {count}',
'{name} (you)': '{name}(你)',
Expand Down
44 changes: 44 additions & 0 deletions core/test/at-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,47 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session
assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' })
assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), [])
})

test('#305: a face room offers each of its Bots once, named Person · Bot, and no friend\'s Bot from outside', async () => {
const face = indexRoster({
self: { memberId: 'm1', displayName: 'SelfB' },
bots: [{ id: 'chief', name: 'Chief', role: 'Chief of Staff', sessionId: 'chief' }],
rooms: [{
id: 'face', name: 'Face-to-face · SelfA, SelfB · 482913', origin: 'face-to-face', members: ['chief'],
participants: [
{ kind: 'bot', id: 'chief', handle: '@chief.selfb.home', displayName: 'Chief' },
{ kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' },
// The same seat twice (a replayed join) is still one row.
{ kind: 'bot', remote: true, id: '@chief.selfa.home', handle: '@chief.selfa.home', displayName: 'Chief', ownerName: 'SelfA' },
{ kind: 'bot', remote: true, id: '@coder.gone.home', handle: '@coder.gone.home', displayName: 'Coder', ownerName: 'Gone', leftSeq: 4 },
],
}],
remoteBots: [{ handle: '@chief.selfa.home', name: 'Chief', home: '@selfa.home', owner: 'SelfA' }, { handle: '@helper.other.home', name: 'Helper', home: '@other.home' }],
})
const rows = mentionRows(face, 'face')
assert.equal(rows.filter(row => row.remote).length, 0, 'no published-friend rows in a face room')
assert.deepEqual(rows.map(row => row.bot?.label ?? 'everyone'), ['SelfB (you) · Chief', 'SelfA · Chief', 'everyone'])
// What is inserted is still the name the room routes on, never a handle.
assert.deepEqual(rows.map(row => row.text), ['@Chief ', "@SelfA's Chief ", '@Everyone '])
assert.ok(rows.every(row => !/@[\w-]+\.[\w-]+\.home/.test(row.text)))
assert.deepEqual(mentionRows(face, 'face', 'selfa').map(row => row.text), ["@SelfA's Chief "])

const [bots] = atSources({
roster: { getSnapshot: () => face },
sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } },
launched: () => false,
iconFor: () => 'bot',
})
const listed = await bots.candidates({ sessionId: 'face' }, { query: '' })
assert.deepEqual(listed.map(row => [row.name, row.description]), [
['SelfB (you) · Chief', undefined],
['SelfA · Chief', undefined],
['Everyone', 'Every Bot in this room'],
])
// Without a member-session actor (roster.self is null) the seat's own owner name is used, as Members does.
const legacy = indexRoster({ ...face, self: null, rooms: [{ ...face.rooms[0], participants: [{ ...face.rooms[0].participants[0], ownerName: 'SelfB' }, ...face.rooms[0].participants.slice(1)] }] })
assert.equal(mentionRows(legacy, 'face')[0].bot.label, 'SelfB (you) · Chief')
// Outside a face room the published friends' Bots still join the menu.
const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] })
assert.equal(mentionRows(group, 'face').filter(row => row.remote).length, 2)
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- Group bubbles, notices, and failed-reply lines never show a raw `<untrusted-message>` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback.
- `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`).
- A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `group-settings.js`, used by the overview and group settings): one row per live Bot, `<person> · <Bot>`, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229).
- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`SelfA · Chief`, `SelfB (you) · Chief`), plus **Everyone** (`Every Bot in this room`; the room's local member list holds only this home's Bots, so it shows no count). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305).

### Hidden DSH tool-removal notices

Expand Down
5 changes: 4 additions & 1 deletion patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,11 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js

// Header
'Message {name}': '给 {name} 发消息',
@@ -154,6 +154,7 @@ const ZH = {
@@ -152,8 +152,10 @@ const ZH = {
'Admin leads': '管理员主持',
'Plain messages go to the admin, who calls others with @Name.': '普通消息先给管理员,由管理员用 @名字 点名其他成员。',
Everyone: '所有人',
+ 'Every Bot in this room': '房间里的所有 Bot',
'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。',
'Members · {count}': '成员 · {count}',
+ '{name} (you)': '{name}(你)',
Expand Down
Loading
Loading