Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1103-face-leave-single-line.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
13 changes: 13 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
19 changes: 19 additions & 0 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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))
}
Expand Down
34 changes: 34 additions & 0 deletions core/test/tool-cells.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<untrusted-message>` 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:<name>`), 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 `<Bot> (<person>)`, 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).
Expand Down
Loading
Loading