From e2a4a41f323b28f4e07419a1b10ff7eb896274e2 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 13:09:07 +0800 Subject: [PATCH] fix(social): one leave line when a face room closes (#1103) Rebased onto 28c55abd (#1107 and #1108 rewrote the same 0008 hunk). patches/ds-bot/0008 is that tree plus this change only. --- .../1103-face-leave-single-line.fixed.md | 1 + core/client.js | 13 +++ core/src/client/cells.js | 19 ++++ core/test/tool-cells.test.js | 34 +++++++ docs/subsystems/core-client.md | 1 + docs/subsystems/social.md | 4 +- patches/ds-bot/0001-core-fixes.patch | 36 ++++++- patches/ds-bot/0003-flybot-api-client.patch | 25 ++++- patches/ds-bot/0008-client-bundle.patch | 55 ++++++----- plugins/social/src/face-rooms.js | 16 ++++ plugins/social/src/social.js | 6 +- plugins/social/test/face-rooms.test.js | 12 +++ plugins/social/test/social.test.js | 95 +++++++++++++++++++ 13 files changed, 289 insertions(+), 28 deletions(-) create mode 100644 changelog.d/1103-face-leave-single-line.fixed.md diff --git a/changelog.d/1103-face-leave-single-line.fixed.md b/changelog.d/1103-face-leave-single-line.fixed.md new file mode 100644 index 00000000..b20db8a0 --- /dev/null +++ b/changelog.d/1103-face-leave-single-line.fixed.md @@ -0,0 +1 @@ +When the other side leaves a face room and that leave closes the room, the room says so once: the "left the room. Room closed." banner is the only line, so the short platform leave no longer appears next to it. A leave that leaves the room open still posts its short line ([#1103](https://github.com/ThinkFlowLab/FlyBot/issues/1103)). diff --git a/core/client.js b/core/client.js index 50ef0232..5619f3f5 100644 --- a/core/client.js +++ b/core/client.js @@ -5631,6 +5631,18 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, } var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; var REACTIONS = ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ™"]; +function leaveNoticeCoveredByClose(room2, text) { + if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; + const match = String(text ?? "").trim().match(/^(.+) left(?: the room)?$/); + if (!match) return false; + const who = match[1]; + for (const participant of room2.participants ?? []) { + if (participant?.remote !== true || participant.leftSeq === void 0) continue; + const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); + if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; +} function useDismiss(open, close, refs) { (0, import_react8.useEffect)(() => { if (!open) return void 0; @@ -6501,6 +6513,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); } if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); } diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 966f0d47..56320d3b 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -58,6 +58,22 @@ export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, const WAITING_OTHERS_NOTICE = "Waiting for the owner's approval" const REACTIONS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™'] +// The closed status line already says who left (#1103). Same rule as +// plugins/social/src/face-rooms.js leaveNoticeCoveredByClose: the plugin does not post the short +// line when it already knows the room closed, and a line posted earlier is not drawn beside the banner. +function leaveNoticeCoveredByClose(room, text) { + if (room?.origin !== 'face-to-face' || room.face?.status !== 'closed') return false + const match = String(text ?? '').trim().match(/^(.+) left(?: the room)?$/) + if (!match) return false + const who = match[1] + for (const participant of room.participants ?? []) { + if (participant?.remote !== true || participant.leftSeq === undefined) continue + const name = String(participant.ownerName || participant.displayName || participant.name || '').trim() + if (name && (who === name || who.endsWith(` (${name})`))) return true + } + return false +} + // Closes a popover on a press outside the given elements or on Escape. function useDismiss(open, close, refs) { useEffect(() => { @@ -920,6 +936,9 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) } if (reply.kind === 'notice' && room?.origin === 'face-to-face' && String(reply.text ?? '').trim() === WAITING_OTHERS_NOTICE) return null + // The closed status line already names this person (#1103). Same rule as the social plugin's + // leaveNoticeCoveredByClose: a short "Name left the room" beside "Name left the room. Room closed." + if ((reply.kind === 'notice' || reply.kind === 'left') && leaveNoticeCoveredByClose(room, reply.text)) return null if (reply.kind === 'notice' || reply.kind === 'joined' || reply.kind === 'left' || reply.kind === 'started') { return h('div', { key: `notice-${index}`, className: 'bt-event', 'data-flybot': 'face-notice' }, remoteMessageText(reply.text)) } diff --git a/core/test/tool-cells.test.js b/core/test/tool-cells.test.js index 2dfa30fb..203618f2 100644 --- a/core/test/tool-cells.test.js +++ b/core/test/tool-cells.test.js @@ -370,6 +370,40 @@ test('#278: a Face-to-face room drops the old "Waiting for the owner\'s approval assert.match(shown(root, { sessionId: 'room' }), /Waiting for the owner/) }) +// #1103: the closed banner already says who left. A short leave notice beside it is the second line. +test('#1103: a closed face room draws the short leave only when the banner does not cover it', () => { + const replies = [ + { kind: 'notice', text: 'SelfB1025 left the room' }, + { kind: 'notice', text: 'Coder (SelfB1025) left' }, + { kind: 'notice', text: 'Carol left the room' }, + { kind: 'notice', text: 'Bob joined the room' }, + { kind: 'notice', text: 'The room is now open. The number is no longer valid.' }, + ] + const closed = { + ...room, origin: 'face-to-face', face: { status: 'closed' }, + participants: [ + { remote: false, displayName: 'Chief', ownerName: 'SelfA1025' }, + { remote: true, displayName: 'Coder', ownerName: 'SelfB1025', leftSeq: 4 }, + ], + } + const root = call('group_relay', 'relay', { content: { replies } }) + const drawn = shown(root, { sessionId: 'room', roster: team({ rooms: [closed], roomsById: { room: closed } }) }) + assert.doesNotMatch(drawn, /SelfB1025 left the room/) + assert.doesNotMatch(drawn, /Coder \(SelfB1025\) left/) + assert.match(drawn, /Carol left the room/) + assert.match(drawn, /Bob joined the room/) + assert.match(drawn, /The room is now open/) + // The room is still open: the short line is the only leave signal. + const open = { ...closed, face: { status: 'open' } } + const live = shown(root, { sessionId: 'room', roster: team({ rooms: [open], roomsById: { room: open } }) }) + assert.match(live, /SelfB1025 left the room/) + assert.match(live, /Coder \(SelfB1025\) left/) + // Closed, but nobody recorded as gone: the banner cannot name them, so the short line stays. + const unnamed = { ...closed, participants: [{ remote: false, displayName: 'Chief' }] } + const bare = shown(root, { sessionId: 'room', roster: team({ rooms: [unnamed], roomsById: { room: unnamed } }) }) + assert.match(bare, /SelfB1025 left the room/) +}) + // The Secrets page says who may use a key with three explicit choices (#545): all my Bots, // chosen Bots, or none; a key for no Bot shows that choice, not a list with nobody in it. test('the scope editor offers all my Bots, chosen Bots, or none, and shows the current one', () => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d86a4bc6..6652baff 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -179,6 +179,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, after New group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. A string returned from `run` is shown as a toast (`actions.notify`). Social registers Face-to-face room and Add friend; see [plugin-runtime](plugin-runtime.md) and [social](social.md). - 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. +- A closed face room's status line already says who left (#1103). `GroupReplies` (`leaveNoticeCoveredByClose` in `cells.js`) does not also draw a short leave notice for that person (`SelfB1025 left the room`, or `Coder (SelfB1025) left`). A leave while the room is still open, a leave the status line does not name, and every other notice stay. The social plugin uses the same rule when it decides whether to post the line. Test: `core/test/tool-cells.test.js`. - `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 is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members ยท N** count people and draw one row per person (`FacePersonRow`: each Bot that person brought, drawn with the look the chat and the sidebar use โ€” the local roster record, or the friend's seat/chat look, stepped off a same-named local Bot โ€” then the person's name and the Bot names; my Bots open their chat). A row with no Bot still draws the person silhouette, not a blank disc (#1089). Owner names that share an initial (`SelfA1025`, `SelfB1025`) are not the mark (#1104). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index b8096fb5..0bdb0d95 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -94,7 +94,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - #193: the account-menu handle row overrides the menu's full-width button rule so the handle text stays visible next to **Copy**. - #194: a face room title shows one time: Bot Social's `created_at`, else the joining deadline minus the 10-minute window (`faceRoomCreatedAt`), the same before and after Enter chat and on every home. - #195: the Add friend paste button reads **Accept invite**; an accepted Bot invite posts a toast and closes the panel at once. -- #191: a peer's leave reads `Bob left the room` (the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (`FaceRoomStatus` on `flybot.session.dock`, `data-flybot=face-room-end`: `Bob left the room. This room is closed.` / `You left this room.`). Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. +- #191: a peer's leave reads `Bob left the room` (the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (`FaceRoomStatus` on `flybot.session.dock`, `data-flybot=face-room-end`: `Bob left the room. Room closed.` / `You left this room.`). A leave that closes the room is that one line (#1103): the shorter `Bob left the room` is not also posted, and a short leave already in the transcript for a person that status line names is not drawn beside it. A leave that leaves the room open still posts the short line. Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. - #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has `data-flybot=face-room-end`), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section: `relationship_exists` is not an error (#496, below), and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. - My own handle (`@name.home`) shows with a **Copy** link at the top of the account menu (slot `flybot.account.header`, `MyHandle`) as well as in Add friend (#181). - Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, which wraps so a whole pasted link shows and accepts on Enter (#499), **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then the shared invite control below), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). Typing in the paste box clears a failed accept's error (#499). After **Accept invite** the result shows: `{person} shared {Bot} with you.`, and about 1.8 s later the panel closes on its own and opens the new friend's Bot when its chat is already listed (#499); when the join brought no Bot (`Joined {person}'s invite, but their Bot hasn't loaded yetโ€ฆ`) or sent a request, the panel stays open until **Done** (#343). **Talk through** stays on one line (nowrap label). Add friend and Bot Sharing draw one invite control (`inviteControls` in `client.js`, #499): before a link there is one button, **Copy invite link for {Bot}** (mint + clipboard); then the link sits on its own full-width read-only row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button that makes a fresh link without copying it (#230). Every copy note names the Bot: `Invite link for {Bot} copied.` / `Invite for {Bot} copied with instructions.`. The Sharing hint is `Invite grants {Bot} only.` and adds `Revoke anytime in People with access.` only once People with access lists someone (`ManageAccess` reports its count through `onGrants`); the empty list says `Nobody has this Bot yet. Copy an invite link above and send it to a friend.` (#499). Below that, **Share with a friend** (#500) lists friends who do not have this Bot yet (one **Share** per friend's Bot, each row named `Bot (Owner)` with that Bot's handle and its own busy state, #973); invite links for new people stay. A read of that list that fails, or that was already in flight when a **Revoke** settled, never replaces the rows a successful read drew: the card updates only from a read that succeeded after the revoke, a failed one keeps the rows and says so with a **Try again**, and `No friends left to share withโ€ฆ` prints only when the last successful read returned nobody (#984). The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief โ†’ the room`), never raw `bot_01โ€ฆ` or a `FLYBOT-FACE-HUMAN` envelope. @@ -187,7 +187,7 @@ Settings โ†’ **Bot Social** lists friends and shares once the server is connecte ### Past rooms, unique titles, poll, and + (#129โ€“#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face ยท Alice, Bob ยท 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): `faceWaitCode` and `shownFaceTitle` refuse a stored value that is not exactly six digits (`1234567` never reads as `123456`) and fall back to the code the owner typed, or to the creation time. -- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` โ†’ `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. This room is closed.` The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face ยท Alice, Bob ยท 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). +- Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` โ†’ `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. A room that drops off Bot Social's list without this home leaving is the same end (#1070): the hosted room is marked `closed` so the sidebar's active list drops it, and Past rooms keeps that one row (a `left` room stays Left). Its `endedAt` is the snapshot's `closed_at` when Bot Social sent one, so both homes show that time; otherwise the first time this home noticed. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read ` left the room. Room closed.` That status line is the one leave signal when the room closes (#1103): the shorter ` left the room` is not also posted into the transcript. The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face ยท Alice, Bob ยท 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). A number that is not empty and not exactly six digits keeps **Join** disabled and shows **Enter 6 digits** under the field (#1052). Six digits clear that line and enable Join once a Bot is picked; an empty field shows nothing. - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - **Open** on a Past room calls `social/face-open` (`plugins/social/src/social.js`): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come from `plugins/social/src/face-title.js`, with participant owners in stable handle order, so both homes show the same name (#172). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7f041aef..352bb82e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -8612,7 +8612,7 @@ diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js }) // #105: the secret and question cards of a PTC program showed nowhere. -@@ -355,3 +355,30 @@ test('PTC secret and question cards show at their sub-calls and act on their own +@@ -355,3 +355,64 @@ test('PTC secret and question cards show at their sub-calls and act on their own assert.doesNotMatch(shown(program([call('ask_user', 'call-0-1:ptc:1')]), { roster }), /Which draft\?/) assert.equal(shown(program([call('ask_user', 'call-1-2:ptc:1')]), { roster: team() }), '') }) @@ -8631,6 +8631,40 @@ diff --git a/test/tool-cells.test.js b/test/tool-cells.test.js + assert.match(shown(root, { sessionId: 'room' }), /Waiting for the owner/) +}) + ++// #1103: the closed banner already says who left. A short leave notice beside it is the second line. ++test('#1103: a closed face room draws the short leave only when the banner does not cover it', () => { ++ const replies = [ ++ { kind: 'notice', text: 'SelfB1025 left the room' }, ++ { kind: 'notice', text: 'Coder (SelfB1025) left' }, ++ { kind: 'notice', text: 'Carol left the room' }, ++ { kind: 'notice', text: 'Bob joined the room' }, ++ { kind: 'notice', text: 'The room is now open. The number is no longer valid.' }, ++ ] ++ const closed = { ++ ...room, origin: 'face-to-face', face: { status: 'closed' }, ++ participants: [ ++ { remote: false, displayName: 'Chief', ownerName: 'SelfA1025' }, ++ { remote: true, displayName: 'Coder', ownerName: 'SelfB1025', leftSeq: 4 }, ++ ], ++ } ++ const root = call('group_relay', 'relay', { content: { replies } }) ++ const drawn = shown(root, { sessionId: 'room', roster: team({ rooms: [closed], roomsById: { room: closed } }) }) ++ assert.doesNotMatch(drawn, /SelfB1025 left the room/) ++ assert.doesNotMatch(drawn, /Coder \(SelfB1025\) left/) ++ assert.match(drawn, /Carol left the room/) ++ assert.match(drawn, /Bob joined the room/) ++ assert.match(drawn, /The room is now open/) ++ // The room is still open: the short line is the only leave signal. ++ const open = { ...closed, face: { status: 'open' } } ++ const live = shown(root, { sessionId: 'room', roster: team({ rooms: [open], roomsById: { room: open } }) }) ++ assert.match(live, /SelfB1025 left the room/) ++ assert.match(live, /Coder \(SelfB1025\) left/) ++ // Closed, but nobody recorded as gone: the banner cannot name them, so the short line stays. ++ const unnamed = { ...closed, participants: [{ remote: false, displayName: 'Chief' }] } ++ const bare = shown(root, { sessionId: 'room', roster: team({ rooms: [unnamed], roomsById: { room: unnamed } }) }) ++ assert.match(bare, /SelfB1025 left the room/) ++}) ++ +// The Secrets page says who may use a key with three explicit choices (#545): all my Bots, +// chosen Bots, or none; a key for no Bot shows that choice, not a list with nobody in it. +test('the scope editor offers all my Bots, chosen Bots, or none, and shows the current one', () => { diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index f6ea3561..2de24c7f 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -472,7 +472,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js import { failureLabel } from './diagnostics.js' import { turnReasoning } from './turn-reasoning.js' -@@ -45,6 +45,9 @@ export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, +@@ -45,8 +45,27 @@ export function Bubbles({ texts, roster, selfId, actions, streaming, sessionId, })) } @@ -481,7 +481,25 @@ diff --git a/src/client/cells.js b/src/client/cells.js +const WAITING_OTHERS_NOTICE = "Waiting for the owner's approval" const REACTIONS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™'] ++// The closed status line already says who left (#1103). Same rule as ++// plugins/social/src/face-rooms.js leaveNoticeCoveredByClose: the plugin does not post the short ++// line when it already knows the room closed, and a line posted earlier is not drawn beside the banner. ++function leaveNoticeCoveredByClose(room, text) { ++ if (room?.origin !== 'face-to-face' || room.face?.status !== 'closed') return false ++ const match = String(text ?? '').trim().match(/^(.+) left(?: the room)?$/) ++ if (!match) return false ++ const who = match[1] ++ for (const participant of room.participants ?? []) { ++ if (participant?.remote !== true || participant.leftSeq === undefined) continue ++ const name = String(participant.ownerName || participant.displayName || participant.name || '').trim() ++ if (name && (who === name || who.endsWith(` (${name})`))) return true ++ } ++ return false ++} ++ // Closes a popover on a press outside the given elements or on Escape. + function useDismiss(open, close, refs) { + useEffect(() => { @@ -101,8 +101,10 @@ function MessageTools({ text, name, sessionId, react, actions }) { } @@ -953,7 +971,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js t('{name} could not answer: {reason}', { name: bot.name, reason: failureLabel(reply.code, reply.text) })) } if (reply.kind === 'lookup') { -@@ -599,14 +599,24 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -599,14 +599,27 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === 'stopped') { return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) } @@ -966,6 +984,9 @@ diff --git a/src/client/cells.js b/src/client/cells.js - h(BotMark, { bot: bot, size: 22, state: speakerTool ? toolState(speakerTool) : 'thinking' }), - h(Shimmer, null, speakerTool ? `${bot.name} ยท ${workPhrase(speakerTool)}โ€ฆ` : t('{name} is typingโ€ฆ', { name: bot.name }))) + if (reply.kind === 'notice' && room?.origin === 'face-to-face' && String(reply.text ?? '').trim() === WAITING_OTHERS_NOTICE) return null ++ // The closed status line already names this person (#1103). Same rule as the social plugin's ++ // leaveNoticeCoveredByClose: a short "Name left the room" beside "Name left the room. Room closed." ++ if ((reply.kind === 'notice' || reply.kind === 'left') && leaveNoticeCoveredByClose(room, reply.text)) return null + if (reply.kind === 'notice' || reply.kind === 'joined' || reply.kind === 'left' || reply.kind === 'started') { + return h('div', { key: `notice-${index}`, className: 'bt-event', 'data-flybot': 'face-notice' }, remoteMessageText(reply.text)) + } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 3940767e..bc000fe0 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3998,7 +3998,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 -@@ -2488,2292 +2488,4079 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,2292 +2488,4092 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -5033,23 +5033,6 @@ diff --git a/client.js b/client.js + pending.answer({ answers: next }); + }; + return (0, import_react5.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+} -+function PendingQuestion({ sessionId, question, actions, inline }) { -+ const [answered, setAnswered] = (0, import_react5.useState)(null); -+ if (answered === question.id) return null; -+ const onAnswer = (answer) => { -+ setAnswered(question.id); -+ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -+ }; -+ const onDismiss = () => { -+ setAnswered(question.id); -+ void actions.dismissQuestion(sessionId); -+ }; -+ return (0, import_react5.createElement)( -+ "div", -+ { className: inline ? "bt-q-inline" : "bt-q-dock" }, -+ (0, import_react5.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -+ ); } -@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} -@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} @@ -5165,6 +5148,23 @@ diff --git a/client.js b/client.js - style.textContent = text; - document.head.appendChild(style); - return style; ++function PendingQuestion({ sessionId, question, actions, inline }) { ++ const [answered, setAnswered] = (0, import_react5.useState)(null); ++ if (answered === question.id) return null; ++ const onAnswer = (answer) => { ++ setAnswered(question.id); ++ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); ++ }; ++ const onDismiss = () => { ++ setAnswered(question.id); ++ void actions.dismissQuestion(sessionId); ++ }; ++ return (0, import_react5.createElement)( ++ "div", ++ { className: inline ? "bt-q-inline" : "bt-q-dock" }, ++ (0, import_react5.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ++ ); ++} +function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { + const question = useRoster((value) => value.questions?.[sessionId]); + const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; @@ -5962,10 +5962,22 @@ diff --git a/client.js b/client.js - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } ++var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; ++var REACTIONS = ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ™"]; ++function leaveNoticeCoveredByClose(room2, text) { ++ if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; ++ const match = String(text ?? "").trim().match(/^(.+) left(?: the room)?$/); ++ if (!match) return false; ++ const who = match[1]; ++ for (const participant of room2.participants ?? []) { ++ if (participant?.remote !== true || participant.leftSeq === void 0) continue; ++ const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); ++ if (name && (who === name || who.endsWith(` (${name})`))) return true; + } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} ++ return false; + } -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; @@ -5992,8 +6004,6 @@ diff --git a/client.js b/client.js - if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); - if (overflow > 0 && index === 3) { - return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -+var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; -+var REACTIONS = ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ™"]; +function useDismiss(open, close, refs) { + (0, import_react8.useEffect)(() => { + if (!open) return void 0; @@ -7613,6 +7623,7 @@ diff --git a/client.js b/client.js + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; ++ if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); + } diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 24727f4e..49c4ce63 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -426,6 +426,22 @@ export function platformLeaveText(member) { return owner ? `${owner} left the room` : `${faceMemberLabel(member)} left` } +// The closed status line already says who left and that the room closed (#1103): +// "Bob left the room. Room closed." The short platform leave is the same fact. +// Same rule as core `leaveNoticeCoveredByClose` (`cells.js`), which hides a line already drawn. +export function leaveNoticeCoveredByClose(room, text) { + if (room?.origin !== 'face-to-face' || room.face?.status !== 'closed') return false + const match = String(text ?? '').trim().match(/^(.+) left(?: the room)?$/) + if (!match) return false + const who = match[1] + for (const participant of room.participants ?? []) { + if (participant?.remote !== true || participant.leftSeq === undefined) continue + const name = String(participant.ownerName || participant.displayName || participant.name || '').trim() + if (name && (who === name || who.endsWith(` (${name})`))) return true + } + return false +} + export function platformLeadText(member, reason) { if (member) return `Room lead: ${faceMemberLabel(member)}` return reason === 'lead_left' ? 'The room lead left. The default lead takes over.' : 'The room lead is back to the default.' diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 8d307044..e16c92a0 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -50,6 +50,7 @@ import { faceOutbound, cleanFaceName, platformJoinText, + leaveNoticeCoveredByClose, humanFaceWho, faceRoomTitle, parseFaceHuman, @@ -3845,7 +3846,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (alreadyJoined(line.text)) continue seen = rememberEvent(seen, `join-text:${line.text}`) } - if (firstTime(flybot.rooms.applyFaceLine?.(room.id, line), line)) void flybot.rooms.postNotice?.(room.id, line.text) + // A leave that closes the room is already the status line ("Bob left the room. Room closed."). + // Posting the short line as well is the second signal (#1103). The log still keeps the event. + const coveredLeave = line.kind === 'left' && leaveNoticeCoveredByClose(room, line.text) + if (firstTime(flybot.rooms.applyFaceLine?.(room.id, line), line) && !coveredLeave) void flybot.rooms.postNotice?.(room.id, line.text) } else if (line.human || line.authorKind === 'human') { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index b22973e5..c3fb26ab 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -19,6 +19,7 @@ import { faceEventKey, platformJoinText, platformLeaveText, + leaveNoticeCoveredByClose, platformStartedText, faceMemberLabel, distinctFaceBotName, @@ -133,6 +134,17 @@ test('peer names and system copy are sanitized', () => { assert.doesNotMatch(text, /not published/) assert.equal(platformLeaveText({ display_name: 'Coder', owner_name: 'Bob' }), 'Bob left the room') assert.equal(platformLeaveText({ display_name: 'Coder' }), 'Coder left') + // #1103: the closed banner already names this person, so the short leave is not a second line. + const closed = { + origin: 'face-to-face', face: { status: 'closed' }, + participants: [{ remote: true, ownerName: 'SelfB1025', displayName: 'Coder', leftSeq: 4 }], + } + assert.equal(leaveNoticeCoveredByClose(closed, 'SelfB1025 left the room'), true) + assert.equal(leaveNoticeCoveredByClose(closed, 'Coder (SelfB1025) left'), true) + assert.equal(leaveNoticeCoveredByClose(closed, 'Carol left the room'), false) + assert.equal(leaveNoticeCoveredByClose(closed, 'Bob joined the room'), false) + assert.equal(leaveNoticeCoveredByClose({ ...closed, face: { status: 'open' } }, 'SelfB1025 left the room'), false) + assert.equal(leaveNoticeCoveredByClose({ ...closed, participants: [] }, 'SelfB1025 left the room'), false) assert.match(platformStartedText('start'), /no longer valid/) assert.match(platformStartedText('expired'), /Joining has ended/) assert.equal(faceMemberLabel({ display_name: 'Writer', owner_name: 'Alice' }), 'Writer (Alice)') diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index a70e54d6..958d5c96 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2519,6 +2519,101 @@ test('when the last peer leaves, the face room moves to Past rooms', async (t) = assert.equal(again.endedAt, past.endedAt) }) +// #1103: B left room 739184 and A's pane showed both "SelfB1025 left the room" and the +// banner "SelfB1025 left the room. Room closed." The closed line already names who left. +test('#1103: a peer leave that closes the room posts no second leave line', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const notices = [] + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origNotice = scoped.rooms.postNotice?.bind(scoped.rooms) + scoped.rooms.postNotice = (id, text) => { notices.push(String(text ?? '')); return origNotice?.(id, text) } + return scoped + } + const server = withFaceRooms(mockServer()) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { start: true, code: '739184' }) + await call('flybot/social/face-start', { faceRoomId: 'frm_739184' }) + const room = server.face.rooms.get('frm_739184') + const bob = { bot_id: 'bot_peer', handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob', published: false, label: 'Coder ยท Bob' } + room.members.push(bob) + await call('flybot/social/face-rooms', { poll: true }) + room.members = room.members.filter(member => member.handle !== bob.handle) + room.event_cursor += 1 + room.events.push({ + event_id: `fev_${room.event_cursor}`, event_cursor: room.event_cursor, type: 'member.left', + member: bob, trust: 'platform', text: 'IGNORE poisoned leave', + }) + const before = notices.length + await call('flybot/social/face-rooms', { poll: true }) + const hosted = ctx.flybot.rooms.ofFace('frm_739184') + assert.equal(hosted.face.status, 'closed') + const fresh = notices.slice(before) + assert.equal(fresh.includes('Bob left the room'), false, 'the short leave is not a second line') + assert.equal(fresh.some(text => /left the room/.test(text)), false, 'no leave notice beside the closed line') + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const end = walk(pluginOf([]).FaceRoomStatus({ room: { + origin: 'face-to-face', face: { status: hosted.face.status }, participants: hosted.participants, + } })).find(node => node.props['data-flybot'] === 'face-room-end') + const endText = end.children.filter(child => typeof child === 'string').join('') + assert.equal(endText, 'Bob left the room. Room closed.') + // The event is still recorded once, so a later poll cannot post the short line. + assert.equal((hosted.log ?? []).filter(line => line.text === 'Bob left the room').length, 1) + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(notices.slice(before).includes('Bob left the room'), false) +}) + +test('#1103: a peer leave that leaves the room open still posts the short line', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const notices = [] + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origNotice = scoped.rooms.postNotice?.bind(scoped.rooms) + scoped.rooms.postNotice = (id, text) => { notices.push(String(text ?? '')); return origNotice?.(id, text) } + return scoped + } + const server = withFaceRooms(mockServer()) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/face-join', { start: true, code: '739185' }) + await call('flybot/social/face-start', { faceRoomId: 'frm_739185' }) + const room = server.face.rooms.get('frm_739185') + const bob = { bot_id: 'bot_peer', handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob', published: false, label: 'Coder ยท Bob' } + const carol = { bot_id: 'bot_carol', handle: '@writer.carol.home', display_name: 'Writer', owner_name: 'Carol', published: false, label: 'Writer ยท Carol' } + room.members.push(bob, carol) + await call('flybot/social/face-rooms', { poll: true }) + room.members = room.members.filter(member => member.handle !== bob.handle) + room.event_cursor += 1 + room.events.push({ + event_id: `fev_${room.event_cursor}`, event_cursor: room.event_cursor, type: 'member.left', + member: bob, trust: 'platform', + }) + const before = notices.length + await call('flybot/social/face-rooms', { poll: true }) + const hosted = ctx.flybot.rooms.ofFace('frm_739185') + assert.equal(hosted.face.status, 'open') + assert.equal(notices.slice(before).includes('Bob left the room'), true) + const { walk, pluginOf } = loadSocialClient(async () => ({})) + assert.equal(walk(pluginOf([]).FaceRoomStatus({ room: { + origin: 'face-to-face', face: { status: hosted.face.status }, participants: hosted.participants, + } })).some(node => node.props?.['data-flybot'] === 'face-room-end'), false) +}) + test('#304: a failed automatic leave is tried again on the next poll', async (t) => { const { ctx, call, stop } = await team() t.after(stop)