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-mention-chip.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A face-to-face @mention now looks the same on both homes: the chip shows `@` and the room name for the Bot (`SelfA · Chief`, as in Members), a line typed on the other home turns into the same chip, and a space typed after a picked name no longer shows as two ([#305](https://github.com/ThinkFlowLab/FlyBot/issues/305)).
48 changes: 36 additions & 12 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,14 @@ async function copiedToClipboard(text, nav = globalThis.navigator) {
return false;
}
}
var trimmed = (value) => String(value ?? "").trim();
function faceAliases(participant, name) {
const owner = trimmed(participant.ownerName);
const handle = trimmed(participant.handle).replace(/^@+/, "");
const title = remoteBotTitle({ displayName: name, name, ownerName: owner, handle: participant.handle });
const names = [name, participant.label, title, owner ? `${name} · ${owner}` : "", owner ? `${name} · ${owner}'s Bot` : "", handle];
return [...new Set(names.map(trimmed).filter(Boolean))];
}

// src/client/flybot-registry.js
var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
Expand Down Expand Up @@ -10512,13 +10520,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
);
}

// src/client/group-settings.js
var import_react25 = require("react");
var ROOM_MODES = [
["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
["everyone", "Everyone", "Every member answers in turn, admin first."]
];
var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
// src/client/face-seats.js
function faceSeats(room2, roster) {
const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human" && p.leftSeq === void 0);
const seats = live.map((p) => {
Expand All @@ -10543,6 +10545,14 @@ function faceSeatLabel(seat) {
if (!seat.person) return seat.bot;
return `${seat.mine ? t("{name} (you)", { name: seat.person }) : seat.person} · ${seat.bot}`;
}

// src/client/group-settings.js
var import_react25 = require("react");
var ROOM_MODES = [
["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
["everyone", "Everyone", "Every member answers in turn, admin first."]
];
var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
var personKey = (seat) => {
const record = seat.record ?? {};
const home = String(record.ownerHome || record.home || "").trim().toLowerCase();
Expand Down Expand Up @@ -10984,13 +10994,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") {
}
return result;
}
function trimmed(value) {
function trimmed2(value) {
const text = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function compact(count) {
for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) {
if (count >= size * 0.9995) return `${trimmed(count / size)}${unit}`;
if (count >= size * 0.9995) return `${trimmed2(count / size)}${unit}`;
}
return String(Math.round(count));
}
Expand Down Expand Up @@ -15111,13 +15121,26 @@ function contentParts(content) {
}
return { text: texts2.join("\n"), attachments, rest };
}
function mentionableBots(roster, sessionId) {
function mentionableBots(roster, sessionId, { fromFriend = false } = {}) {
const room2 = roster.roomsById[sessionId];
if (room2?.origin === "face-to-face") return faceMentionables(room2, roster, fromFriend);
const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : [];
const humans = (room2?.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 }));
const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }));
return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) })));
}
function faceMentionables(room2, roster, fromFriend) {
const humans = (room2.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 }));
const seats = faceSeats(room2, roster);
const ordered = fromFriend ? [...seats.filter((seat) => !seat.mine), ...seats.filter((seat) => seat.mine)] : seats;
const bots = ordered.flatMap((seat) => {
const target = seat.local ?? { id: seat.key, name: seat.bot, kind: "bot", remote: seat.record };
const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person };
const mentionLabel = faceSeatLabel(seat);
return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel }));
});
return bots.concat(humans);
}
function messageAuthor(data, roster, sessionId) {
const source = data?.source ?? {};
const authorId = source.authorId;
Expand All @@ -15141,6 +15164,7 @@ function userTextParts(text, bots, referenceLabels = []) {
if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) });
parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) });
cursor = span.end;
if (text.startsWith(" ", cursor)) cursor += 1;
}
if (cursor < text.length) parts.push({ text: text.slice(cursor) });
return parts;
Expand All @@ -15150,7 +15174,7 @@ function UserText({ text, bots, referenceLabels, skillNames, references, actions
"button",
{ key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) },
(0, import_react37.createElement)(BotMark, { bot: part.bot, size: 16 }),
part.bot.mentionLabel ?? part.label
`@${part.bot.mentionLabel ?? part.label}`
) : (0, import_react37.createElement)(import_react37.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)));
}
function Attachments({ attachments, renderMessageImages }) {
Expand Down Expand Up @@ -15212,7 +15236,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma
text !== "" || rest.length > 0 ? (0, import_react37.createElement)(
"div",
{ className: "bt-ubody", title: clock3(data.time) },
(0, import_react37.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId), referenceLabels, skillNames: data.skillNames ?? [], references, actions }),
(0, import_react37.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }),
rest.map((block, index) => ui.JsonBlock ? (0, import_react37.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null)
) : null,
referenceLabels.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null
Expand Down
33 changes: 33 additions & 0 deletions core/src/client/face-seats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Who sits in a face-to-face room, named one way everywhere: the Members panel, the waiting
// tiles and the @ chips in a bubble (#229, #305). Pure, so the bubble module can use it.
import { t } from './i18n.js'
import { cleanPeerName, ownerNameOf } from '../shared/remote-label.js'

// A face-to-face room lists people, each with the Bot they brought, named one way for every
// member: "Bob · Chief", "Ann (you) · Chief" (#229). Names come from the participant records
// (server member fields for a friend, the roster for my own Bot), never from message text.
export function faceSeats(room, roster) {
const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human' && p.leftSeq === undefined)
const seats = live.map((p) => {
const local = p.remote ? undefined : roster.byId[p.id]
return {
key: p.handle || p.id,
local,
record: p,
person: local ? cleanPeerName(p.ownerName || roster.self?.displayName, '') : ownerNameOf(p),
bot: local ? local.name : cleanPeerName(p.displayName || p.name, 'Bot'),
mine: Boolean(local),
}
})
for (const id of room.members ?? []) {
const bot = roster.byId[id]
if (!bot || seats.some(seat => seat.local?.id === id)) continue
seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ''), bot: bot.name, mine: true })
}
return seats
}

export function faceSeatLabel(seat) {
if (!seat.person) return seat.bot
return `${seat.mine ? t('{name} (you)', { name: seat.person }) : seat.person} · ${seat.bot}`
}
32 changes: 4 additions & 28 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Card, Row, InlineText, SwitchRow } from './bot-settings.js'
import { Segmented } from './motion.js'
import { FolderMenu } from './folder-menu.js'
import { t } from './i18n.js'
import { cleanPeerName, ownerNameOf, remoteBotTitle } from '../shared/remote-label.js'
import { ownerNameOf, remoteBotTitle } from '../shared/remote-label.js'

export const ROOM_MODES = [
['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'],
Expand All @@ -16,34 +16,10 @@ export const ROOM_MODES = [
// Without an admin the host answers as everyone, whatever the stored mode says.
export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone')

// A face-to-face room lists people, each with the Bot they brought, named one way for every
// member: "Bob · Chief", "Ann (you) · Chief" (#229). Names come from the participant records
// (server member fields for a friend, the roster for my own Bot), never from message text.
export function faceSeats(room, roster) {
const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human' && p.leftSeq === undefined)
const seats = live.map((p) => {
const local = p.remote ? undefined : roster.byId[p.id]
return {
key: p.handle || p.id,
local,
record: p,
person: local ? cleanPeerName(p.ownerName || roster.self?.displayName, '') : ownerNameOf(p),
bot: local ? local.name : cleanPeerName(p.displayName || p.name, 'Bot'),
mine: Boolean(local),
}
})
for (const id of room.members ?? []) {
const bot = roster.byId[id]
if (!bot || seats.some(seat => seat.local?.id === id)) continue
seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ''), bot: bot.name, mine: true })
}
return seats
}
// faceSeats and faceSeatLabel live in face-seats.js so the chat bubble can name a mention the same way.
import { faceSeats, faceSeatLabel } from './face-seats.js'

export function faceSeatLabel(seat) {
if (!seat.person) return seat.bot
return `${seat.mine ? t('{name} (you)', { name: seat.person }) : seat.person} · ${seat.bot}`
}
export { faceSeats, faceSeatLabel }

const personKey = (seat) => {
const record = seat.record ?? {}
Expand Down
31 changes: 27 additions & 4 deletions core/src/client/user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { mentionSpans } from '../shared/mentions.js'
import { BotMark, PersonMark } from './mark.js'
import { CheckIcon, CopyIcon } from './icons.js'
import { dateLocale, t } from './i18n.js'
import { remoteBotTitle } from '../shared/remote-label.js'
import { faceAliases, remoteBotTitle } from '../shared/remote-label.js'
import { faceSeatLabel, faceSeats } from './face-seats.js'

// -------------------------------------------------------------------------
// The user's own messages. DSH draws every "@word" in them as a file reference, and
Expand Down Expand Up @@ -32,8 +33,9 @@ export function contentParts(content) {
// A friend's remote Bot is also called by its handle — the @ menu inserts "@<handle>"
// and the host delivers on it — so the handle joins as a second name of the same Bot,
// and the chip reads the display name (mentionLabel) from the roster.
export function mentionableBots(roster, sessionId) {
export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) {
const room = roster.roomsById[sessionId]
if (room?.origin === 'face-to-face') return faceMentionables(room, roster, fromFriend)
const bots = room ? room.members.map(id => roster.byId[id]).filter(Boolean)
: roster.byId[sessionId] ? roster.bots : []
const humans = (room?.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined)
Expand All @@ -44,6 +46,24 @@ export function mentionableBots(roster, sessionId) {
.map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) })))
}

// A face-to-face line was typed on either home, so a Bot is matched by every name the room
// host routes on (faceAliases: "SelfA's Chief" typed on B is A's own Chief on A) and the chip
// reads as the Members panel names it, "SelfA · Chief", the same on both homes (#305).
// A bare "@Chief" means the writer's own Chief: a friend's line tries the friend's Bots first.
function faceMentionables(room, roster, fromFriend) {
const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined)
.map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role }))
const seats = faceSeats(room, roster)
const ordered = fromFriend ? [...seats.filter(seat => !seat.mine), ...seats.filter(seat => seat.mine)] : seats

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve local-first precedence for own face-room lines

When a face-room snapshot lists a remote seat before this home's seat (for example, the join-order shape on the later home) and both Bots share a name such as the default Chief, the fromFriend === false branch leaves the remote candidate first. mentionSpans then binds this home's own @Chief to that remote seat, while host faceCandidates deliberately puts local candidates first, so the bubble labels and opens the friend's Chief even though the local Chief was called. Order local seats first for locally authored lines so the client mirrors host precedence.

Useful? React with 👍 / 👎.

const bots = ordered.flatMap((seat) => {
const target = seat.local ?? { id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record }
const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person }
const mentionLabel = faceSeatLabel(seat)
return faceAliases(record, seat.bot).map(name => ({ ...target, name, mentionLabel }))
})
return bots.concat(humans)
}

export function messageAuthor(data, roster, sessionId) {
const source = data?.source ?? {}
const authorId = source.authorId
Expand All @@ -69,6 +89,9 @@ export function userTextParts(text, bots, referenceLabels = []) {
if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) })
parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) })
cursor = span.end
// The @ menu inserts "@Name " with its space, so a typed space after it makes two; the chip
// is followed by one (#305).
if (text.startsWith(' ', cursor)) cursor += 1
}
if (cursor < text.length) parts.push({ text: text.slice(cursor) })
return parts
Expand All @@ -77,7 +100,7 @@ export function userTextParts(text, bots, referenceLabels = []) {
function UserText({ text, bots, referenceLabels, skillNames, references, actions }) {
return userTextParts(text, bots, referenceLabels).map((part, index) => (part.bot
? h('button', { key: index, type: 'button', className: 'bt-umention', title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) },
h(BotMark, { bot: part.bot, size: 16 }), part.bot.mentionLabel ?? part.label)
h(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`)
: h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references))))
}

Expand Down Expand Up @@ -134,7 +157,7 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes
author && (incoming || multiHuman) ? h('span', { className: 'bt-uauthor' }, author.name, author.ownerName ? h('span', { className: 'bt-chip' }, t('Friend\'s Bot')) : null) : null,
attachments.length > 0 ? h(Attachments, { attachments, renderMessageImages }) : null,
text !== '' || rest.length > 0 ? h('div', { className: 'bt-ubody', title: clock(data.time) },
h(UserText, { text, bots: mentionableBots(roster, sessionId), referenceLabels, skillNames: data.skillNames ?? [], references, actions }),
h(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }),
rest.map((block, index) => (ui.JsonBlock ? h(ui.JsonBlock, { key: index, label: t('Content'), payload: block }) : null))) : null,
referenceLabels.length > 0 ? h('div', { className: 'bt-urefs' }, t('Referenced {labels}', { labels: referenceLabels.join(t(', ')) })) : null),
text === '' ? null : h('div', { className: 'bt-utools' }, h(CopyButton, { text })))
Expand Down
13 changes: 4 additions & 9 deletions core/src/host/face-mentions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,19 @@
// A friend's Bot is only ever matched here to be told apart from a local one: the local home
// never starts a turn for it. Pure: the room and the Bot lookup come in.
import { mentionSpans, unmatchedMentions } from '../shared/mentions.js'
import { remoteBotTitle } from '../shared/remote-label.js'
import { faceAliases } from '../shared/remote-label.js'
import { currentParticipants, isRemoteParticipant } from './room-membership.js'

const clean = value => String(value ?? '').trim()

// The aliases are shared with the chat bubble, which draws the same names as chips (#305).
export { faceAliases }

// A handle as one comparable key ("@Chief.Bob" and "chief.bob" are the same Bot), or ''.
export const handleKey = value => clean(value).replace(/^@+/, '').toLowerCase()
// The handle a mention list carries: lowercase, with the "@" Bot Social writes.
const wireHandle = value => (handleKey(value) === '' ? '' : `@${handleKey(value)}`)

// Every name one participant answers to, longest first is left to mentionSpans.
export function faceAliases(participant, name) {
const owner = clean(participant.ownerName)
const handle = clean(participant.handle).replace(/^@+/, '')
const title = remoteBotTitle({ displayName: name, name, ownerName: owner, handle: participant.handle })
const names = [name, participant.label, title, owner ? `${name} · ${owner}` : '', owner ? `${name} · ${owner}'s Bot` : '', handle]
return [...new Set(names.map(clean).filter(Boolean))]
}

// { local: [{ name, bot }], remote: [{ name, participant }] }: one entry per alias.
export function faceCandidates(room, botOf, { fromFriend = false } = {}) {
Expand Down
13 changes: 13 additions & 0 deletions core/src/shared/remote-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,16 @@ export async function copiedToClipboard(text, nav = globalThis.navigator) {
return false
}
}

const trimmed = value => String(value ?? '').trim()

// Every name one face-room participant answers to (#287): its own name, the label the picker gave
// it, the other home's title for it, `Bot · Person` and the bare handle. Longest first is left to
// mentionSpans. The host routes on these and the bubble draws them as one chip (#305).
export function faceAliases(participant, name) {
const owner = trimmed(participant.ownerName)
const handle = trimmed(participant.handle).replace(/^@+/, '')
const title = remoteBotTitle({ displayName: name, name, ownerName: owner, handle: participant.handle })
const names = [name, participant.label, title, owner ? `${name} · ${owner}` : '', owner ? `${name} · ${owner}'s Bot` : '', handle]
return [...new Set(names.map(trimmed).filter(Boolean))]
}
Loading
Loading