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/455-face-room-other-home-lines.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms: another person's line, or another home's approved Bot reply, that arrives while your Bots are answering now shows right away as that person's or Bot's bubble, never as a queued "[Group post from …]" row above the composer that Cmd/Ctrl+Enter could steer; a person's line breaks are kept on the receiving side ([#455](https://github.com/ThinkFlowLab/FlyBot/issues/455)).
3 changes: 3 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3804,6 +3804,8 @@ var CONVERSATION_CSS = `
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
/* A person's typed line keeps its line breaks, as on the sender's side (#455): Markdown would join them. */
.bt-person-msg .bt-bubble :is(p,li){white-space:pre-line}
.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))}
.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap}
Expand Down Expand Up @@ -5619,6 +5621,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us
return unreadOpening ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)("div", { className: "bt-open-new" }, (0, import_react8.createElement)(NewDivider), (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body;
function triggerBody() {
if (source.form === "remote-chat") return null;
if (source.shown === true) return null;
if (source.remote?.handle) {
const message = (0, import_react8.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi });
return source.remote.firstContact === true ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)(FirstContact, { source, roster, sessionId }), message) : message;
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl
function triggerBody() {
// A friend's message in its remote chat: the reply below shows it (remote-chat.js).
if (source.form === 'remote-chat') return null
// A line that arrived while a round was running is that round's bubble already (#455).
if (source.shown === true) return null
if (source.remote?.handle) {
const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi })
return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,8 @@ export const CONVERSATION_CSS = `
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
/* A person's typed line keeps its line breaks, as on the sender's side (#455): Markdown would join them. */
.bt-person-msg .bt-bubble :is(p,li){white-space:pre-line}
.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))}
.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap}
Expand Down
63 changes: 54 additions & 9 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,17 +516,13 @@ export function install(rt) {
// late failure, its work picked up after a restart, or its work the user stopped is
// an event of the round.
const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice'
for (const post of posts.filter(item => !item.kickoff && !event(item))) {
// A post a running round already showed and logged when it arrived (#455) is not logged again.
for (const post of posts.filter(item => !item.kickoff && !event(item) && !item.shown)) {
// Image readings go into the log only; `@` names are picked from what the user typed.
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
// A face-room line the send path already logged and sent is not logged or sent again (#287).
if (!post.botId && takePosted(room.id, post.text)) continue
appendRoom(room, rt.authoredRoomLine?.(room, post.botId
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, authorId: post.botId, authorKind: 'bot', text }
: { who: post.authorName || 'User', text, authorId: post.authorId, authorKind: post.authorKind || 'human' })
?? (post.botId
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
: { who: 'User', text }))
logPost(room, post, text)
}
if (posts.some(post => !post.botId && !post.kickoff)) {
room.botRounds = 0
Expand Down Expand Up @@ -581,10 +577,10 @@ export function install(rt) {
appendRoom(room, { who: name, botId: post.botId, kind, text: '' })
round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() })
}
for (const post of posts.filter(item => item.notice || item.botId === 'face-notice')) {
for (const post of posts.filter(item => (item.notice || item.botId === 'face-notice') && !item.shown)) {
const text = String(post.text ?? '')
if (!text) continue
if (post.human) round.replies.push({ kind: 'user', other: true, text, authorId: post.human.authorId, authorKind: 'human', name: post.human.name, time: Date.now() })
if (post.human) round.replies.push(personReply(post.human, text))
else round.replies.push({ kind: 'notice', text, time: Date.now() })
}
if (posts.some(post => post.resumed || post.stopped)) await rt.save()
Expand Down Expand Up @@ -746,10 +742,59 @@ export function install(rt) {
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
}

// A post's line in the room log, as the relay writes it: a Bot's (mine or another home's) or a person's.
function logPost(room, post, text) {
return appendRoom(room, rt.authoredRoomLine?.(room, post.botId
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, authorId: post.botId, authorKind: 'bot', text }
: { who: post.authorName || 'User', text, authorId: post.authorId, authorKind: post.authorKind || 'human' })
?? (post.botId
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
: { who: 'User', text }))
}
// Another person's own line as a round shows it: their bubble, never one of mine (#180).
const personReply = (human, text) => ({ kind: 'user', other: true, text, authorId: human.authorId, authorKind: 'human', name: human.name, time: Date.now() })

// Another person's line or another home's Bot's line that arrives while the group's Session is in a turn
// (#455). DSH would hold a followup in the composer's queue until the turn ends, shown raw with its
// "[Group post from …]" header and steerable as if the owner had typed it. Instead the round that is
// running shows it at once, as that person's or Bot's bubble (a Bot's line is logged then too, so the
// Bots still to speak see it; a person's line Bot Social delivered is logged already), and the Session
// gets it once the turn is done, for the round its own rules open (who answers, if anyone).
const heldPosts = new Map()
const fromElsewhere = extra => extra.human !== undefined || extra.remote !== undefined
function showInRound(room, sender, text, extra) {
const round = [...rt.roomProgress.values()].findLast(entry => entry.roomId === room.id)
if (round === undefined || text === '') return false
if (extra.human) {
round.replies.push(personReply(extra.human, text))
return true
}
logPost(room, { botId: sender.id, name: sender.name }, text)
void rt.save().catch(() => {})
// Not a reply to the round, so a round report leaves it out (kind), and the chat draws the friend's Bot.
round.replies.push({ kind: 'remote', remote: true, botId: sender.id, handle: extra.remote.handle, name: sender.name, text, time: Date.now() })
return true
}
function holdPost(room, agent, sender, text, extra) {
const shown = extra.shown === true || showInRound(room, sender, text, extra)
const held = heldPosts.get(room.id) ?? []
heldPosts.set(room.id, [...held, { sender, text, extra: shown ? { ...extra, shown: true } : extra }])
if (held.length > 0) return
void Promise.resolve().then(() => agent.whenIdle()).then(async () => {
const waiting = heldPosts.get(room.id) ?? []
heldPosts.delete(room.id)
for (const post of waiting) await postToRoom(rt.roomOf(room.id) ?? room, post.sender, post.text, post.extra)
}).catch(error => rt.warn('ds-bot: a line held for %s was not delivered: %s', room.name, error))
}

// A Bot's message into the group's own Session, which relays it as a new round.
// Chips are stripped here and again in deliver() for Bot-to-Bot own-chat followups (#106).
async function postToRoom(room, sender, text, extra = {}) {
const agent = await rt.resolveAgent(room.id)
if (fromElsewhere(extra) && agent.status === 'running' && typeof agent.whenIdle === 'function') {
holdPost(room, agent, sender, stripSessionChips(text), extra)
return
}
agent.followup(createUserMessage({
content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${stripSessionChips(text)}` }],
source: { kind: SOURCE_KIND, form: 'relay', role: 'post', exchangeId: randomUUID(), senderName: sender.name, senderSessionId: sender.id, roomId: room.id, hop: 0, ...extra },
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export function install(rt) {
...(source.quiet ? { quiet: true } : {}),
...(source.notice ? { notice: true } : {}),
...(source.human ? { human: source.human } : {}),
// Shown (and logged) by the round that was running when it arrived (groups.js, #455).
...(source.shown === true ? { shown: true } : {}),
...(source.plain ? { plain: true } : {}),
...(source.everyone ? { everyone: true } : {}),
...(Array.isArray(source.callIds) && source.callIds.length > 0 ? { callIds: source.callIds.filter(id => typeof id === 'string') } : {}),
Expand Down
19 changes: 19 additions & 0 deletions core/test/face-people.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,22 @@ test('another home member\'s line shows their person mark and a received surface
// The bundle carries the same rule.
assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\)/)
})

test('#455: a line a running round already showed draws no second bubble, and a person\'s line keeps its line breaks', async () => {
const { TriggerCell } = await load('cells.js')
const { CONVERSATION_CSS } = await load('styles.js')
const roster = { bots: [], byId: {}, exchangeTurns: {}, roomsById: { g1: { id: 'g1', origin: 'face-to-face', members: [], participants: [] } } }
const cell = shown => TriggerCell({
node: { data: { turn: 3, source: { kind: 'bot', role: 'post', remote: { handle: '@researcher.selfb.home' }, senderName: 'Researcher', senderSessionId: '@researcher.selfb.home', ...(shown ? { shown: true } : {}) }, content: [{ type: 'text', text: '[Group post from Researcher]\nTry the corner shop.' }] } },
sessionId: 'g1',
useRoster: select => select(roster),
useSessions: select => select({ byId: {}, projectionsBySession: {} }),
useTurnClock: select => select({}),
useUi: select => select({}),
actions: { noteTurnPeer() {} },
})
assert.notEqual(cell(false), null, 'a line that opens its own round is drawn by its trigger')
assert.equal(cell(true), null, 'one the running round showed is not drawn again')
assert.match(CONVERSATION_CSS, /\.bt-person-msg \.bt-bubble :is\(p,li\)\{white-space:pre-line\}/)
assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.bt-person-msg \.bt-bubble :is\(p,li\)\{white-space:pre-line\}/)
})
59 changes: 59 additions & 0 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1081,3 +1081,62 @@ test('#299: two owner lines queued together are still routed by the newest one',
assert.equal(result.replies.filter(reply => reply.kind === undefined).length, 1)
assert.ok(writer)
})

test('#455: another person\'s line and another home\'s Bot line that arrive mid-turn show in the running round, never in the Session queue', async (t) => {
const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = start()
t.after(stop)
await team(call, sent)
await rt.load()
const chief = (await call('state', {})).value.mainBotId
const hosted = await rt.upsertFaceRoom({
roomId: 'frm_455',
localId: 'face-455',
status: 'open',
members: [{ handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }],
localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }],
})
// The group's Session is in a turn (its round is running): a followup would wait in DSH's composer queue.
const queued = []
let busy = true
let markIdle
const idle = new Promise(resolve => { markIdle = resolve })
const resolve = ctx.sessionController.resolveAgent
ctx.sessionController.resolveAgent = async sessionId => sessionId !== hosted.id ? resolve(sessionId) : ({
agent: { session: { id: sessionId }, get status() { return busy ? 'running' : 'idle' }, whenIdle: () => idle, followup: message => queued.push(message) },
})
sent.length = 0
await emit(hosted.id, userMessage('Hi, we just met. Can someone suggest a lunch spot?'))
const running = relay(hosted.id, 'r455')
const turn = await turnFor(sent)
assert.ok(turn, 'my Chief is answering')

const lines = 'Line one from B\nLine two: I like noodles\nLine three: budget 50'
await rt.postToRoom(hosted, { id: 'face-notice', name: 'SelfB' }, lines, { quiet: true, notice: true, human: { authorId: '@chief.selfb.home', name: 'SelfB' }, plain: true })
await rt.postToRoom(hosted, { id: '@researcher.selfb.home', name: 'Researcher' }, 'Since B likes noodles, try the corner shop.', { remote: { handle: '@researcher.selfb.home' }, senderName: 'Researcher' })
// What the open chat polls while the round runs, and what waits in the composer queue meanwhile.
const waiting = queued.length
const live = structuredClone([...rt.roomProgress.values()].find(entry => entry.roomId === hosted.id)?.replies ?? [])
const logged = hosted.log.filter(line => line.authorId === '@researcher.selfb.home').length
await answer(turn.sessionId, turn.message.source.exchangeId, 'How about noodles?', 1)
const done = JSON.parse(await running)
assert.equal(waiting, 0, 'nothing waits in the composer queue')
// Both lines are in the running round at once, the person's with its line breaks, neither with a header.
assert.deepEqual(live.map(({ kind, other, name, text }) => ({ kind, other, name, text })), [
{ kind: 'user', other: true, name: 'SelfB', text: lines },
{ kind: 'remote', other: undefined, name: 'Researcher', text: 'Since B likes noodles, try the corner shop.' },
])
assert.equal(logged, 1, 'the Bot line is in the room for the Bots still to speak')
assert.deepEqual(done.replies.map(reply => reply.kind ?? 'bot'), ['user', 'remote', 'bot'])

// Once the turn is done the Session gets both lines, marked as shown, for their own rounds.
busy = false
markIdle()
await waitFor(() => (queued.length === 2 ? true : undefined))
assert.deepEqual(queued.map(message => [message.source.role, message.source.shown]), [['post', true], ['post', true]])
for (const message of queued) await emit(hosted.id, { type: 'user/message', data: { source: message.source, content: message.content } })
// Both fold into one relay here; the person's line is not drawn twice and the Bot line is not logged twice.
sent.length = 0
const again = JSON.parse(await relay(hosted.id, 'r455-next'))
assert.equal(again.replies.filter(reply => reply.kind === 'user' || reply.kind === 'remote').length, 0)
assert.equal(hosted.log.filter(line => line.authorId === '@researcher.selfb.home').length, 1)
})
Loading
Loading