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/117-face-room-two-instance.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms keep a compact Friends' Bots row (short open/joining chip so the name and Start fit), drop the empty header after Leave, label waiting tiles with the owner (`Chief · BoxB`), revive a rejoined Bot instead of duplicating it and still post a join line when the other side's first event is missing, refresh the rooms card and Friends' Bots without a reload, show member avatars on the sidebar row and chat header, and fan an owner's typed line to the other instance as a human message in that room. ([#117](https://github.com/ThinkFlowLab/FlyBot/issues/117))
36 changes: 34 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1728,9 +1728,41 @@ function clusterSeats(count, frame) {
const half = frame / 2;
return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]];
}
function roomAvatarPeople(room2, roster) {
const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean);
const seen = new Set(members.map((bot) => bot.id));
const remotes = [];
for (const p of room2?.participants ?? []) {
if (p.leftSeq !== void 0) 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);
}
continue;
}
const handle = p.handle || (p.remote ? p.id : "");
if (!handle) continue;
if (members.some((bot) => bot.remote?.handle === handle || bot.handle === handle)) continue;
const key = `h:${handle}`;
if (seen.has(key)) continue;
seen.add(key);
remotes.push(lookBotForRemote({
handle,
displayName: p.displayName,
name: p.displayName,
label: p.label,
color: p.color,
avatar: p.avatar
}, roster));
}
if (members.length === 1 && remotes.length === 0) return [members[0], null];
return [...members, ...remotes];
}
function RoomAvatar({ room: room2, roster, size = 36 }) {
const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
const people = members.length === 1 ? [members[0], null] : members;
const people = roomAvatarPeople(room2, roster);
const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } });
if (people.length === 0) return (0, import_react.createElement)(BotMark, { size });
if (size <= 20 && people.length > 1) {
Expand Down
40 changes: 36 additions & 4 deletions core/src/client/mark.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createElement as h, useEffect, useRef, useState } from 'react'
import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js'
import { fnv1a, lookOf, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js'
import { t } from './i18n.js'

export { colorOf, lookOf, remoteSeed, lookBotForRemote } from './characters.js'
Expand Down Expand Up @@ -130,10 +130,42 @@ function clusterSeats(count, frame) {
return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]
}

export function roomAvatarPeople(room, roster) {
const members = (room?.members ?? []).map(id => roster?.byId?.[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)
}
continue
}
const handle = p.handle || (p.remote ? p.id : '')
if (!handle) continue
if (members.some(bot => bot.remote?.handle === handle || bot.handle === handle)) continue
const key = `h:${handle}`
if (seen.has(key)) continue
seen.add(key)
remotes.push(lookBotForRemote({
handle,
displayName: p.displayName,
name: p.displayName,
label: p.label,
color: p.color,
avatar: p.avatar,
}, roster))
}
if (members.length === 1 && remotes.length === 0) return [members[0], null]
return [...members, ...remotes]
}

export function RoomAvatar({ room, roster, size = 36 }) {
const members = room.members.map(id => roster.byId[id]).filter(Boolean)
// A group with one Bot left shows the user beside it.
const people = members.length === 1 ? [members[0], null] : members
const people = 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
74 changes: 56 additions & 18 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ export function isFaceRoom(room) {
return room?.origin === 'face-to-face'
}

function faceStatusFrom(spec, current) {
const status = spec?.status
if (status === 'open' || status === 'closed' || status === 'left') return status
if (spec?.startedAt) return 'open'
if (status) return status
return current || 'candidate'
}

export function presentRoom(room, actor, state) {
if (!room) return room
const role = viewerRole(room, actor, state)
Expand Down Expand Up @@ -1062,6 +1070,7 @@ export function install(rt) {
const handle = String(member.handle || member.id || '')
const displayName = cleanPeerName(member.displayName || member.name, handle)
const ownerName = cleanPeerName(member.ownerName || member.ownerHome, '')
const label = cleanPeerName(member.label, '')
return {
kind: 'bot',
remote: true,
Expand All @@ -1070,6 +1079,7 @@ export function install(rt) {
ownerHome: member.ownerHome,
ownerName,
displayName,
...(label ? { label } : {}),
// Face-to-face: the matching room is the scope. No person-grant is implied.
roomOnly: member.roomOnly === true || member.published !== true,
role: 'member',
Expand All @@ -1080,18 +1090,49 @@ export function install(rt) {
}

function faceLocalRecord(bot, joinedSeq, at, member) {
const label = cleanPeerName(member?.label, '')
return {
kind: 'bot',
id: bot.id,
role: 'member',
displayName: bot.name ?? bot.id,
handle: bot.handle,
ownerName: cleanPeerName(member?.ownerName, ''),
...(label ? { label } : {}),
joinedSeq,
joinedAt: at,
}
}

function faceSameBot(p, { local, handle }) {
if (local) return p.kind === 'bot' && !p.remote && p.id === local.id
return p.remote && (p.handle === handle || p.id === handle)
}

function faceLiveKey(p, byHandle) {
const key = p.remote
? (p.handle || p.id)
: (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id)
return String(key || '').toLowerCase()
}

function applyFaceMemberFields(existing, member) {
if (existing.remote) {
existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName)
existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
existing.roomOnly = member.roomOnly === true || member.published !== true
const label = cleanPeerName(member.label, '')
if (label) existing.label = label
const look = faceLookFields(member)
if (look.color) existing.color = look.color
if (look.avatar) existing.avatar = look.avatar
} else {
if (member.ownerName) existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
const label = cleanPeerName(member.label, '')
if (label) existing.label = label
}
}

rt.syncFaceParticipants = function syncFaceParticipants(room, members, localBots = []) {
materialize(room)
const at = Date.now()
Expand All @@ -1106,36 +1147,33 @@ export function install(rt) {
if (!handle) continue
liveKeys.add(handle.toLowerCase())
const local = byHandle.get(handle.toLowerCase())
const existing = currentParticipants(room).find(p => {
if (local) return p.kind === 'bot' && !p.remote && p.id === local.id
return p.remote && (p.handle === handle || p.id === handle)
})
const matches = (room.participants ?? []).filter(p => faceSameBot(p, { local, handle }))
const existing = matches.find(p => p.leftSeq === undefined) || matches.at(-1)
if (existing) {
if (existing.remote) {
existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName)
existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
existing.roomOnly = member.roomOnly === true || member.published !== true
const look = faceLookFields(member)
if (look.color) existing.color = look.color
if (look.avatar) existing.avatar = look.avatar
} else if (member.ownerName) {
existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
if (existing.leftSeq !== undefined) {
delete existing.leftSeq
delete existing.leftAt
existing.joinedSeq = seq
existing.joinedAt = at
}
applyFaceMemberFields(existing, member)
continue
}
const record = local
? faceLocalRecord(local, seq, at, member)
: faceRemoteRecord(member, seq, at)
room.participants.push(record)
}
const seenLive = new Set()
for (const p of currentParticipants(room)) {
const key = (p.remote ? (p.handle || p.id) : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) || '')
const match = String(key).toLowerCase()
const match = faceLiveKey(p, byHandle)
const still = [...liveKeys].some(h => h === match || h === String(p.id).toLowerCase() || (p.handle && h === String(p.handle).toLowerCase()))
if (!still) {
if (!still || (match && seenLive.has(match))) {
p.leftSeq = seq
p.leftAt = at
continue
}
if (match) seenLive.add(match)
}
syncMembers(room)
return room
Expand Down Expand Up @@ -1163,13 +1201,13 @@ export function install(rt) {
seq: 0,
seen: {},
participants: [],
face: { roomId: serverId, status: spec.status || 'candidate', expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] },
face: { roomId: serverId, status: faceStatusFrom(spec, 'candidate'), expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] },
}
state.rooms[id] = room
}
room.face = {
roomId: serverId,
status: spec.status || room.face.status || 'candidate',
status: faceStatusFrom(spec, room.face?.status),
expiresAt: spec.expiresAt ?? room.face.expiresAt ?? null,
startedAt: spec.startedAt ?? room.face.startedAt ?? null,
eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0),
Expand Down
33 changes: 31 additions & 2 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { build } from 'esbuild'
// The inbound friend conversation's display: trace lines, the thinking row, the remote speaker.
const { outputFiles } = await build({
stdin: {
contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'",
contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
Expand All @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = module.exports
const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports

test('a reply opening with a delegation trace shows without it', () => {
assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.')
Expand Down Expand Up @@ -115,6 +115,35 @@ test('lookBotForRemote seeds a distinct look from the handle when there is no ch
assert.equal(lookBotForRemote(person, { bots: [chat] }), chat)
})

test('room avatars include live remote face-to-face members', () => {
const roster = {
byId: { a: { id: 'a', name: 'Chief', color: 'blue', avatar: { shape: 'whale' } } },
bots: [{ id: 'r1', name: 'Coder', remote: { handle: '@coder.bob.home' }, color: 'green' }],
}
assert.equal(typeof roomAvatarPeople, 'function')
const empty = roomAvatarPeople({ members: [], participants: [] }, roster)
assert.ok(Array.isArray(empty))
assert.equal(empty.length, 0)
const face = roomAvatarPeople({
members: ['a'],
participants: [
{ kind: 'bot', id: 'a', handle: '@a.me.home' },
{ kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', leftSeq: undefined },
{ kind: 'bot', remote: true, handle: '@gone.bob.home', displayName: 'Gone', leftSeq: 3 },
],
}, roster)
assert.equal(face[0].id, 'a')
assert.equal(face[1].remote.handle, '@coder.bob.home')
assert.equal(face.length, 2)
const onlyRemote = roomAvatarPeople({
members: [],
participants: [{ kind: 'bot', remote: true, handle: '@bot.friend.home', displayName: '助手' }],
}, { byId: {}, bots: [] })
assert.equal(onlyRemote.length, 1)
assert.equal(onlyRemote[0].remote.handle, '@bot.friend.home')
assert.notEqual(onlyRemote[0].id, undefined)
})

test('handles equal modulo @ and case share one look; a stored look still wins', () => {
const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } })
const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } })
Expand Down
28 changes: 28 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,34 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async
assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined)
})

test('face-to-face rejoin revives a left participant instead of duplicating', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
const spec = {
roomId: 'frm_rejoin',
localId: 'face-rejoin',
status: 'open',
localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }],
}
const both = [
{ handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false },
{ handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, label: 'Coder · Bob' },
]
await rt.upsertFaceRoom({ ...spec, members: both })
const room = rt.state.rooms['face-rejoin']
await rt.upsertFaceRoom({ ...spec, members: [both[0]] })
const left = room.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(left.length, 1)
assert.ok(left[0].leftSeq !== undefined)
await rt.upsertFaceRoom({ ...spec, members: both })
const again = room.participants.filter(p => p.handle === '@coder.bob.home')
assert.equal(again.length, 1)
assert.equal(again[0].leftSeq, undefined)
assert.equal(again[0].label, 'Coder · Bob')
await rt.upsertFaceRoom({ ...spec, status: 'open', startedAt: '2026-10-16T10:00:00.000Z', members: both })
assert.equal(room.face.status, 'open')
})

test('fanout to remotes fences peer text and never names unpublished specialists', async (t) => {
const { rt, sent } = host(t, TEAM)
await rt.load()
Expand Down
4 changes: 4 additions & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t

The default card (`flybot-slots.js`) shows where a held request came from: `senderName` / `targetName` resolved server-side, plus a delegated marker when the send was relayed (`hop > 0`). The strings live in `i18n.js` (`Sent by {sender}`, `Sent by {sender} · delegated`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md).

### Room avatars in face-to-face chats

- `RoomAvatar` (`src/client/mark.js` `roomAvatarPeople`) seats current local `room.members` and live remote `participants` (handle-seeded via `lookBotForRemote`). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header.

### Remote Bot display

- A friend's remote Bot is stored and delivered by its handle (`@<bot>.<home>` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9).
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t
### Face-to-face participant look

- `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant.
- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**.

### FlyBot display copy

Expand Down
4 changes: 4 additions & 0 deletions docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots

- `flybot.rooms.applyInbound` turns a Bot Social inbox envelope into the owner's inbound-share projection; `leaveInbound` hides those rooms when the invited Bot is removed or its grant is revoked. Face-to-face methods are unchanged. Still not team mode.

### Face-to-face owner lines

- `onFaceLine` receives every appended room line, including the owner's typed human lines. The social plugin sends those through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the matching room sees the text; incoming envelopes apply as `authorKind: 'human'`. Bot replies stay bot-authored. Still not team mode.

### Host Bot look for classic-script plugins

- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`.
Expand Down
Loading
Loading