diff --git a/changelog.d/455-face-room-other-home-lines.fixed.md b/changelog.d/455-face-room-other-home-lines.fixed.md new file mode 100644 index 00000000..ed7676cb --- /dev/null +++ b/changelog.d/455-face-room-other-home-lines.fixed.md @@ -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)). diff --git a/core/client.js b/core/client.js index 823ad1b4..ad62636e 100644 --- a/core/client.js +++ b/core/client.js @@ -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} @@ -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; diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 95d23c5f..61e30097 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -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 diff --git a/core/src/client/styles.js b/core/src/client/styles.js index f3a0f0a2..c452235b 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -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} diff --git a/core/src/host/groups.js b/core/src/host/groups.js index dc2f6a0d..310c8f81 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -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 @@ -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() @@ -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 }, diff --git a/core/src/host/turns.js b/core/src/host/turns.js index fea3ef89..a5bbe7df 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -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') } : {}), diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js index 89c7a964..fa2738b4 100644 --- a/core/test/face-people.test.js +++ b/core/test/face-people.test.js @@ -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\}/) +}) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index fc807283..c005e197 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -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) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 7e80c794..d93f0ac3 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -117,6 +117,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **New group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add coding agent**; every row has its own mark (`ChatIcon`, `BotIcon`, `PeopleIcon`, a plugin row's `icon`, drawn through `SlotIcon` behind its own error boundary with the people mark as fallback; a function or memo/forwardRef component, `TerminalIcon`), one verb style (New … / Add …) and one short hint with no closing period (#385: *Message one of your Bots*, *Set up a Bot with its own role*, *Chat with several of your Bots*, *Meet people nearby with a 6-digit number*, *Paste an invite link or code*, *A coding session without a Bot*). A Bot's right-click menu has **Edit Bot** and no separate **Rename Bot** (the name is edited there); **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126, #181). - Group bubbles, notices, and failed-reply lines never show a raw `` 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`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). +- A person's line keeps its line breaks (#455): `.bt-person-msg .bt-bubble :is(p,li)` is `white-space: pre-line`, as the sender's own `pre-wrap` bubble, since Markdown would join single newlines. A trigger whose source has `shown: true` (a line a running round already drew, host #455) draws nothing. Covered by `core/test/face-people.test.js`. - A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `face-seats.js`, re-exported by `group-settings.js`, used by the overview, group settings and the bubble's @ chips): one row per live Bot, ` · `, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229). - **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). The chat header, tab title, composer placeholder, row tooltip, approval overlay (`flybot-slots.js` `recipientName`), the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row) and in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` (`Chief (Bob)`, my own Bot `Chief (you)`) all use it. `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 5a43f8b3..6869578f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -112,6 +112,7 @@ credentials and team mode remain separate work under #55/#198. - A face room's auto title (`faceRoomFallbackTitle`) is built from every member the room has had (the poll's snapshot plus stored participants) and the creation time (`faceRoomCreatedAt`: `spec.createdAt`, else the joining deadline minus the 10-minute window, else the start; #194), so every home shows the same title and a member who left is not dropped from it (#182). - `upsertFaceRoom` keeps the six-digit number a room was joined by (`spec.joinCode` / `spec.code`) on `room.face.joinCode`, and the auto title uses it whenever a later upsert (a poll, a leave) does not repeat it, so the title never falls back from the number to a date (#304). - Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`. +- **Lines from elsewhere mid-turn (#455):** a `postHuman` or `postRemote` post (`source.human` / `source.remote`) that arrives while the group Session is `running` is not `followup`ed, which would park it in DSH's composer queue (raw `[Group post from …]`, steerable). `postToRoom` holds it (`heldPosts`): the round that is running (`rt.roomProgress`) shows it at once, as `personReply` or a `{ kind: 'remote' }` reply (left out of round reports), and a Bot's line is logged then (a person's line is in the log already through `applyFaceLine`); after `agent.whenIdle()` it is `followup`ed with `shown: true`, so its own round routes it by the usual rules but `group_relay` neither shows nor logs it again. With no round running it is only held and goes as before. Covered by `core/test/rounds.test.js`. - `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`. - The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`. - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), before the kickoff, so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1f5c0c9f..6080623c 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2197,12 +2197,14 @@ diff --git a/src/host/turns.js b/src/host/turns.js // A message that shares a step with a bare handoff package is what the turn // answers, so the turn is not a hidden acknowledgement. const bare = info => info?.handoff === true && info.resume !== true -@@ -331,11 +331,18 @@ export function install(rt) { +@@ -331,11 +331,20 @@ export function install(rt) { hop: source.hop ?? 0, ...(source.late ? { late: true } : {}), ...(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') } : {}), diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 5d8a8030..241db02e 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -700,12 +700,14 @@ diff --git a/src/client/cells.js b/src/client/cells.js // A centered event line that names a group chat and opens it. function GroupEvent({ prefix, room, name, roster, actions }) { return h('div', { className: 'bt-event' }, -@@ -331,17 +331,28 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl +@@ -331,17 +331,30 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl : body 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 @@ -2261,7 +2263,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -782,6 +782,12 @@ export const CONVERSATION_CSS = ` +@@ -782,6 +782,14 @@ export const CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} @@ -2270,6 +2272,8 @@ diff --git a/src/client/styles.js b/src/client/styles.js +.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} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 9353c3be..1db79517 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1233,7 +1233,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) -@@ -347,3 +347,354 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,413 @@ test('a message sent between rounds still opens a full round', async (t) => { const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -1588,3 +1588,62 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + 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) ++}) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ec76c2aa..22d1df96 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2172,7 +2172,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2599,6 +2599,12 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +2599,14 @@ var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} @@ -2181,6 +2181,8 @@ diff --git a/client.js b/client.js +.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} @@ -3957,7 +3959,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4135,30 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -3965,6 +3967,7 @@ diff --git a/client.js b/client.js + 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; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index af3a3727..90296f48 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -704,15 +704,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (known !== undefined) { - await rt.ensureCapabilities(known) - return { id: known, brief: undefined } -- } -- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) -- room.sessions[member.id] = id -- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) -- delete room.seen[member.id] -- const ref = await rt.desiredModel(member) -- if (ref !== undefined) { -- await rt.selectSessionModel(id, splitRef(ref)) -- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -725,6 +716,15 @@ diff --git a/src/host/groups.js b/src/host/groups.js + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) } +- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) +- room.sessions[member.id] = id +- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) +- delete room.seen[member.id] +- const ref = await rt.desiredModel(member) +- if (ref !== undefined) { +- await rt.selectSessionModel(id, splitRef(ref)) +- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) +- } - await rt.applyPermission(member, id) - .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) - await rt.save() @@ -1021,13 +1021,15 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -400,35 +400,50 @@ export function install(rt) { +@@ -400,35 +400,46 @@ export function install(rt) { // The opening greeting is a cue for the admin, not a line of the group; a member's // 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 +- for (const post of posts.filter(item => !item.kickoff && !event(item))) { + 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') - appendRoom(room, post.botId @@ -1035,12 +1037,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js - : { who: 'User', text }) + // 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 @@ -1091,10 +1088,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js 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() @@ -1120,7 +1117,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,25 +548,71 @@ export function install(rt) { +@@ -548,25 +548,120 @@ export function install(rt) { // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1183,10 +1180,59 @@ diff --git a/src/host/groups.js b/src/host/groups.js 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${text}` }], + content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${stripSessionChips(text)}` }], diff --git a/patches/ds-bot/files/test/face-people.test.js b/patches/ds-bot/files/test/face-people.test.js index 89c7a964..fa2738b4 100644 --- a/patches/ds-bot/files/test/face-people.test.js +++ b/patches/ds-bot/files/test/face-people.test.js @@ -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\}/) +})