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/297-peer-thinking.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's Bot now shows **Name is thinking…** above the composer while it works on an answer, in a direct chat with it and in a face-to-face room for Bots on other homes, and the line ends when its message arrives. It uses Bot Social's new ephemeral `presence` signal (handle, name and state only; never text, never stored) and only between Bots that may already talk; against a Bot Social without it nothing is sent or shown. Needs the Bot Social build that lists `presence` in `GET /v1/platform`. ([#297](https://github.com/ThinkFlowLab/FlyBot/issues/297))
10 changes: 9 additions & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
| `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). |
| `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). |
| `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. |
| `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. |
| `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. |
| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. |
| `plugins/social/client.js` | `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. |

## Entry points

Expand Down Expand Up @@ -116,6 +117,13 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_
- **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You.
- **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit.

### Thinking status for friends' Bots (#297)

- **Signal.** Bot Social's `presence` feature (`GET /v1/platform` `features`; the same probe as `typed_by_owner` / `mentions`, `serverPresence()`): MCP `set_presence { bot_id, state: thinking|idle, to | room_id }` and `list_presence`. Ephemeral and content-free: handle, display name and state only, TTL 20 s, 30 writes/min per Bot, never stored. The server allows it only between Bots with an active relationship (direct) or members of an open room, and shows an entry only to the other side. Plugin option `presence: false` turns it off here. Against an older server (feature absent, or an unknown tool) nothing is sent, polled or shown.
- **Send (`src/presence.js`).** On the feed's `turn.start`, a turn in a Bot's own chat triggered by a friend's line (`trigger.from.kind === 'remote'`, to that Bot's handle) or a turn of a Bot in an **open** face-to-face room (to that room's server id) says `thinking` for the Bot's Bot Social identity (`social.member.bot_id`), repeats every 10 s (cap 60 beats), and says `idle` at `turn.end`. A turn the user started, a turn in a local group, a Bot with no identity, a read-only host, and a turn that ended while the feature probe ran send nothing. A refused signal (`no_relationship`, `face_room_not_found`, …) stops that turn's heartbeat; `rate_limited` is skipped.
- **Receive.** `social/typing` (host endpoint) is asked every 2 s by the `PeerThinking` dock of an open friend chat or open face room; each ask keeps a 2.5 s `list_presence` poll alive for 15 s, so nothing polls when no social chat is on screen. Expiry is measured on this host's clock from the server's remaining time. A Bot's message (`remote.chats.deliver`, face-room `postRemote`) ends its line at once and the lagging server entry is ignored for 4 s.
- **UI.** One quiet line above the composer (`flybot.session.dock` `social-thinking`, below the approval card): `Coder is thinking…`, `A and B are thinking…`, `A, B and 2 more are thinking…`, in the core `bt-shimmer` style of the activity row. In a direct chat it uses the chat's own Bot name; in a room the participant's name, with ` · Owner` when two share one. It shows only for that chat's Bot or that room, never for a left/closed/candidate room. Permissions are unchanged: the signal neither grants nor bypasses anything.

### 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.
Expand Down
4 changes: 4 additions & 0 deletions plugins/social/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root
When you leave the last face room or the room closes, the plugin revokes it (`remove_home_member`).
A failed join that created a new room-only identity is revoked too. A later person-grant reuses
the handle and drops `roomOnly`. **Manage access** on the Bot lists who has it and **Revoke**.
- **Thinking status** (#297): a friend's Bot shows `Name is thinking…` above the composer in its chat and in a
face-to-face room while it works, through Bot Social's ephemeral `presence` signal (`set_presence` / `list_presence`;
handle, name and state only). Sent only for turns that answer a friend or run in an open room; polled only while such a
chat is on screen; nothing happens against a Bot Social that does not list `presence`. Option `presence: false` turns it off.
- **Remote roster**: `list_remote_bots` → friends' Bots published into the main Bot list
(globe mark). Each row uses the published `avatar` / `color` when the server sends them,
else a handle-seeded look. Sidebar section `flybot.sidebar.section#social` keeps **N to approve**,
Expand Down
59 changes: 59 additions & 0 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ window.__ModuleLoader__.load({
.fs-status[data-tone=error]{color:#e5484d}
.fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff}
.fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400}
.fs-thinking{margin:0 12px 6px;padding-inline-start:8px;font-size:13px;line-height:20px;min-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fs-room-end{margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;line-height:16px}
/* A left or closed face room is read-only (#197): its end line replaces the composer. */
[class*="_composerStack"]:has([data-flybot="face-room-end"]) [class*="_root"]:has(> [data-composer-card]){display:none!important}
Expand Down Expand Up @@ -964,6 +965,60 @@ window.__ModuleLoader__.load({
return text ? h('div', { className: 'fs-room-end', 'data-flybot': 'face-room-end', role: 'status' }, text) : null
}


// "Name is thinking…" for friends' Bots (#297): one quiet line above the composer of a friend's chat or an
// open face room. The host polls Bot Social only while this is mounted (`social/typing`); an older
// server has no signal, so the line never shows and nothing else changes.
function thinkingLine(names) {
const list = names.filter(Boolean)
if (list.length === 0) return ''
if (list.length === 1) return `${list[0]} is thinking…`
if (list.length === 2) return `${list[0]} and ${list[1]} are thinking…`
return `${list[0]}, ${list[1]} and ${list.length - 2} more are thinking…`
}
// Which entries belong to this chat, and what to call them: the chat's own name for a friend's Bot, the
// room's participant name (with the owner when two share a name) in a face room.
function thinkingNames(items, { room, bot } = {}) {
const handle = typeof bot?.remote?.handle === 'string' ? bot.remote.handle.trim().toLowerCase() : ''
const faceId = room?.origin === 'face-to-face' && room.face?.status === 'open' ? room.face.roomId : ''
const seen = new Set()
const out = []
for (const item of Array.isArray(items) ? items : []) {
const mine = faceId
? item.scope === 'face_room' && item.roomId === faceId
: handle !== '' && item.scope === 'direct' && String(item.handle).toLowerCase() === handle
if (!mine || seen.has(item.handle)) continue
seen.add(item.handle)
if (!faceId) { out.push(String(bot.name || item.name || '').trim()); continue }
const people = (room.participants ?? []).filter(p => p.remote)
const own = people.find(p => String(p.handle ?? '').toLowerCase() === String(item.handle).toLowerCase())
const name = String(own?.displayName || own?.name || item.name || '').trim()
const clash = own && people.filter(p => String(p.displayName || p.name || '').trim() === name).length > 1
out.push(clash && own.ownerName ? `${name} · ${own.ownerName}` : name)
}
return out
}
function PeerThinking({ room, bot } = {}) {
useStyles()
const [items, setItems] = useState([])
const watching = Boolean((typeof bot?.remote?.handle === 'string' && bot.remote.handle) || (room?.origin === 'face-to-face' && room.face?.roomId && room.face.status === 'open'))
useEffect(() => {
if (!watching) { setItems([]); return undefined }
let live = true
const load = () => {
if (typeof document !== 'undefined' && document.hidden) return
call('social/typing', {}).then(value => { if (live) setItems(value?.supported === false ? [] : (Array.isArray(value?.typing) ? value.typing : [])) }, () => { if (live) setItems([]) })
}
load()
if (typeof setInterval !== 'function') return () => { live = false }
const timer = setInterval(load, 2000)
timer?.unref?.()
return () => { live = false; clearInterval(timer) }
}, [watching, bot?.remote?.handle, room?.id, room?.face?.roomId, room?.face?.status])
const text = thinkingLine(thinkingNames(items, { room, bot }))
return text ? h('div', { className: 'fs-thinking', 'data-flybot': 'peer-thinking', role: 'status', 'aria-live': 'polite' }, h('span', { className: 'bt-shimmer' }, text)) : null
}

function ChatApprovalDock({ sessionId, roster, room, bot, actions, claim } = {}) {
useStyles()
const [items, setItems] = useState([])
Expand Down Expand Up @@ -1611,6 +1666,9 @@ window.__ModuleLoader__.load({
friendLabel,
MyHandle,
FaceRoomStatus,
PeerThinking,
thinkingLine,
thinkingNames,
FriendBotRow,
FriendChatSettings,
FaceToFaceGroup,
Expand Down Expand Up @@ -1652,6 +1710,7 @@ window.__ModuleLoader__.load({
slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }),
slots().register('flybot.session.dock', { id: 'social-room-end', order: 40, component: FaceRoomStatus }),
slots().register('flybot.session.dock', { id: 'social-approval', order: 50, policy: 'social', component: ChatApprovalDock }),
slots().register('flybot.session.dock', { id: 'social-thinking', order: 60, component: PeerThinking }),
]
// For other plugins (the tree's per-node "Copy invite link"): ctx.flybot.social.canInvite(botId)
// is synchronous; flybot.call('social.createInvite', { botId }) returns { link, text, ... }.
Expand Down
1 change: 1 addition & 0 deletions plugins/social/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// approval { default: 'hold' | 'auto', homes: { '@bob.home': 'auto' } }
// inboxPollMs poll members' inboxes this often while idle; 0 = off (default 15000)
// facePollMs faster poll while a live face-to-face room is open (default 4000)
// presence false turns off "Name is thinking…" for friends' Bots (Bot Social presence signal, #297; default on)
// token / botId legacy per-bot identity (no home): owner connector token + bot id
import { join } from 'node:path'
import { registerUnavailable, startFailureReason, startSocial } from './src/social.js'
Expand Down
Loading
Loading