From 1dbadf338109ba1f11c61fd864d022c0bd17116a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 10:34:09 +0000 Subject: [PATCH] fix(social): sync face human lines and slim waiting UI Face-room send posts a human line instead of a local bot prompt so the other FlyBot instance sees it. Waiting UI is a tile grid; leave after joining ends still goes to Past rooms; live rows stop stacking Open/Start/Leave. The sidebar + is a short menu, Add friend mints an invite, and in-chat holds use readable names. Fixes #124 Fixes #125 Fixes #126 Co-authored-by: Danyang Chen --- changelog.d/124-face-human-sync.fixed.md | 1 + changelog.d/125-face-waiting-leave.fixed.md | 1 + changelog.d/126-social-usability.changed.md | 1 + core/client.js | 37 +++- core/src/client/details-drawer.js | 8 +- core/src/client/group-settings.js | 4 + core/src/client/i18n.js | 1 + core/src/client/new-chat.js | 23 +- core/src/client/styles.js | 1 + core/src/host/channel.js | 17 +- core/src/host/room-membership.js | 21 +- core/src/host/turns.js | 1 + core/test/new-chat.test.js | 6 +- core/test/remote-room.test.js | 7 + core/test/rounds.test.js | 32 +++ docs/subsystems/core-client.md | 3 +- docs/subsystems/core-host.md | 4 + docs/subsystems/plugin-runtime.md | 4 +- docs/subsystems/social.md | 10 +- patches/ds-bot/0001-core-fixes.patch | 16 ++ patches/ds-bot/0003-flybot-api-client.patch | 61 +++++- patches/ds-bot/0005-social-seams.patch | 68 +++++- patches/ds-bot/0008-client-bundle.patch | 91 +++++--- patches/ds-bot/0012-room-membership.patch | 30 ++- .../ds-bot/files/src/host/room-membership.js | 21 +- patches/ds-bot/files/test/new-chat.test.js | 6 +- patches/ds-bot/files/test/remote-room.test.js | 7 + patches/ds-bot/topics.json | 3 +- plugins/social/README.md | 6 +- plugins/social/client.js | 201 ++++++++++++++---- plugins/social/src/face-rooms.js | 44 ++-- plugins/social/src/social.js | 20 +- plugins/social/test/face-rooms.test.js | 17 +- plugins/social/test/social.test.js | 148 ++++++++++++- 34 files changed, 770 insertions(+), 151 deletions(-) create mode 100644 changelog.d/124-face-human-sync.fixed.md create mode 100644 changelog.d/125-face-waiting-leave.fixed.md create mode 100644 changelog.d/126-social-usability.changed.md diff --git a/changelog.d/124-face-human-sync.fixed.md b/changelog.d/124-face-human-sync.fixed.md new file mode 100644 index 000000000..5b6a96a17 --- /dev/null +++ b/changelog.d/124-face-human-sync.fixed.md @@ -0,0 +1 @@ +Typing in a face-to-face room posts a human line to Bot Social (`FLYBOT-FACE-HUMAN`) and shows it on the other FlyBot instance. Send no longer goes through the local Bot prompt, so it does not bump turns or stay local-only. ([#124](https://github.com/ThinkFlowLab/FlyBot/issues/124)) diff --git a/changelog.d/125-face-waiting-leave.fixed.md b/changelog.d/125-face-waiting-leave.fixed.md new file mode 100644 index 000000000..648c45544 --- /dev/null +++ b/changelog.d/125-face-waiting-leave.fixed.md @@ -0,0 +1 @@ +Face-to-face waiting is a tile grid (avatar above name and owner) with Leave and Enter chat on one row. Leave after joining has ended still moves the room to Past rooms. Past rows are unique with Open only; live rows in Friends' Bots are click-to-open with Leave, not Open/Start/Leave stacked. Join copy uses the owner and Bot name. ([#125](https://github.com/ThinkFlowLab/FlyBot/issues/125)) diff --git a/changelog.d/126-social-usability.changed.md b/changelog.d/126-social-usability.changed.md new file mode 100644 index 000000000..925970a13 --- /dev/null +++ b/changelog.d/126-social-usability.changed.md @@ -0,0 +1 @@ +The sidebar + menu is a short list of actions (not a New chat composer). Add friend has one-click Copy invite link. Friends' Bots keeps the N to approve badge and shows holds in that chat with readable names, not raw bot ids. ([#126](https://github.com/ThinkFlowLab/FlyBot/issues/126)) diff --git a/core/client.js b/core/client.js index 36cd8fe96..e56c6d7f7 100644 --- a/core/client.js +++ b/core/client.js @@ -431,6 +431,7 @@ var ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", + "Add Agent": "添加 Agent", "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", @@ -2826,6 +2827,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} +.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:8px 12px;box-shadow:none} .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} @@ -9160,6 +9162,7 @@ function NewChat({ mode, roster, actions }) { const [query, setQuery] = (0, import_react21.useState)(""); const [picked, setPicked] = (0, import_react21.useState)([]); const [group, setGroup] = (0, import_react21.useState)(mode === "group"); + const [compose, setCompose] = (0, import_react21.useState)(mode !== "new"); const [admin, setAdmin] = (0, import_react21.useState)(null); const [message, setMessage] = (0, import_react21.useState)(""); const [busy, setBusy] = (0, import_react21.useState)(false); @@ -9262,11 +9265,20 @@ function NewChat({ mode, roster, actions }) { actions.openSession(room2.id); close(); }); + const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === ""; const options = []; if (!group && picked.length === 0 && mode !== "search" && wanted === "") { + if (menuOnly) { + options.push({ key: "chat", icon: (0, import_react21.createElement)(PlusIcon), label: t("New chat"), run: () => { + setCompose(true); + setCursor(0); + setTimeout(() => inputRef.current?.focus()); + } }); + } options.push({ key: "new", icon: (0, import_react21.createElement)(PlusIcon), label: t("Create new Bot"), run: createBot }); options.push({ key: "group", icon: (0, import_react21.createElement)(PeopleIcon), label: t("Create group chat"), run: () => { setGroup(true); + setCompose(true); setCursor(0); inputRef.current?.focus(); } }); @@ -9280,7 +9292,7 @@ function NewChat({ mode, roster, actions }) { }); } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { + options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Agent"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -9327,8 +9339,13 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); { className: "bt-pane", style: { left: paneLeft } }, (0, import_react21.createElement)( "div", - { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat") }, - (0, import_react21.createElement)( + { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat"), "data-menu": menuOnly || void 0 }, + menuOnly ? (0, import_react21.createElement)( + "div", + { className: "bt-to" }, + (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("New…")), + (0, import_react21.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") + ) : (0, import_react21.createElement)( "div", { className: "bt-to" }, (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("To:")), @@ -9402,9 +9419,9 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); option.hint && index === active ? (0, import_react21.createElement)("span", { className: "bt-option-hint" }, option.hint) : null )) )), - (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), + menuOnly ? null : (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, - (0, import_react21.createElement)( + menuOnly ? null : (0, import_react21.createElement)( "div", { className: "bt-nc-compose" }, (0, import_react21.createElement)( @@ -9780,6 +9797,10 @@ var ROOM_MODES = [ ]; var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; function roomSubtitle(room2, roster) { + if (room2.origin === "face-to-face") { + const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.leftSeq === void 0); + return t("{count} members", { count: live.length }); + } const admin = roster.byId[room2.admin]; return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); } @@ -12356,8 +12377,8 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti room2.sharedFrom.invitedAt ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), - (0, import_react32.createElement)( + face ? null : (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : (0, import_react32.createElement)( "div", { key: "members" }, (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), @@ -12409,7 +12430,7 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti }) ]) ), - (0, import_react32.createElement)( + face ? null : (0, import_react32.createElement)( "div", { key: "mode" }, (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Who answers")), diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 1d803ce90..c89fdebdc 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -176,8 +176,10 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) : room.notice ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), - h(SharedRoomApprovals, { key: 'approvals', room }), - h('div', { key: 'members' }, + face ? null : h(SharedRoomApprovals, { key: 'approvals', room }), + face && (room.face?.status === 'candidate' || room.face?.status === 'joining') + ? null + : h('div', { key: 'members' }, h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length + humans.length + remotes.length })), h('div', { className: 'bt-members', role: 'list' }, [ ...humans.map(person => h('div', { key: person.id, role: 'listitem' }, @@ -201,7 +203,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) room.admin === member.id ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null), entry ? h(MemberWork, { entry, bot: member, room, roster, actions, now }) : null) })])), - h('div', { key: 'mode' }, h('div', { className: 'bt-section-title' }, t('Who answers')), + face ? null : h('div', { key: 'mode' }, h('div', { className: 'bt-section-title' }, t('Who answers')), h('div', { className: 'bt-kv' }, h('span', null, t(ROOM_MODES.find(([id]) => id === shownMode(room, roster))?.[1] ?? 'Everyone')))), usage ? h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')), h('div', { className: 'bt-kv' }, h('span', null, room.name), h('span', null, usage))) : null, diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index a58a62649..8fd32666d 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -17,6 +17,10 @@ export const ROOM_MODES = [ export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone') export function roomSubtitle(room, roster) { + if (room.origin === 'face-to-face') { + const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.leftSeq === undefined) + return t('{count} members', { count: live.length }) + } const admin = roster.byId[room.admin] return [t('{count} members', { count: room.members.length }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ') } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 4a8c99109..9dd167eca 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -370,6 +370,7 @@ const ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. 'Add DSH Agent': '添加 DSH Agent', + 'Add Agent': '添加 Agent', 'Remove DSH Agent': '移除 DSH Agent', 'New session': '新会话', Archive: '归档', diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 9f7330ed3..3dab31f73 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -13,6 +13,8 @@ export function NewChat({ mode, roster, actions }) { const [query, setQuery] = useState('') const [picked, setPicked] = useState([]) const [group, setGroup] = useState(mode === 'group') + // Sidebar + starts as a short action menu; the To:/composer opens after New chat. + const [compose, setCompose] = useState(mode !== 'new') // A new group chat picks its admin first, then the other members. const [admin, setAdmin] = useState(null) const [message, setMessage] = useState('') @@ -106,10 +108,14 @@ export function NewChat({ mode, roster, actions }) { actions.openSession(room.id) close() }) + const menuOnly = mode === 'new' && !compose && !group && picked.length === 0 && wanted === '' const options = [] if (!group && picked.length === 0 && mode !== 'search' && wanted === '') { + if (menuOnly) { + options.push({ key: 'chat', icon: h(PlusIcon), label: t('New chat'), run: () => { setCompose(true); setCursor(0); setTimeout(() => inputRef.current?.focus()) } }) + } options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot }) - options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) + options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCompose(true); setCursor(0); inputRef.current?.focus() } }) for (const action of newChatActionRows({ mode, group, picked, query, roster })) { options.push({ key: action.key, @@ -119,9 +125,9 @@ export function NewChat({ mode, roster, actions }) { run: () => action.run({ close, actions, roster }), }) } - // A team holds at most one DSH Agent; the entry exists only while there is none. + // A team holds at most one Agent; the entry exists only while there is none. if ((roster.agents ?? []).length === 0) { - options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) + options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } for (const bot of matches) { @@ -150,8 +156,11 @@ export function NewChat({ mode, roster, actions }) { else if (admin !== null) setAdmin(null) } return h('div', { className: 'bt-pane', style: { left: paneLeft } }, - h('div', { className: 'bt-newchat', role: 'dialog', 'aria-label': mode === 'search' ? t('Search') : t('New chat') }, - h('div', { className: 'bt-to' }, + h('div', { className: 'bt-newchat', role: 'dialog', 'aria-label': mode === 'search' ? t('Search') : t('New chat'), 'data-menu': menuOnly || undefined }, + menuOnly ? h('div', { className: 'bt-to' }, + h('span', { className: 'bt-to-label' }, t('New…')), + h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Close new chat'), onClick: close }, '×')) + : h('div', { className: 'bt-to' }, h('span', { className: 'bt-to-label' }, t('To:')), adminBot ? h('span', { key: adminBot.id, className: 'bt-token bt-token-admin', title: t('Admin') }, h(BotMark, { bot: adminBot, size: 16 }), adminBot.name, h(AdminStar), @@ -187,9 +196,9 @@ export function NewChat({ mode, roster, actions }) { option.bot ? h(BotMark, { bot: option.bot, size: 22 }) : h('span', { className: 'bt-option-icon' }, option.icon), h('span', { className: 'bt-option-label' }, option.label), option.hint && index === active ? h('span', { className: 'bt-option-hint' }, option.hint) : null)))), - h('div', { className: 'bt-nc-spacer' }), + menuOnly ? null : h('div', { className: 'bt-nc-spacer' }), error ? h('div', { className: 'bt-error' }, error) : null, - h('div', { className: 'bt-nc-compose' }, + menuOnly ? null : h('div', { className: 'bt-nc-compose' }, h('div', { className: 'bt-nc-card' }, h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), h('textarea', { diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 31a3d4c4f..5b3919bb4 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -284,6 +284,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} +.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:8px 12px;box-shadow:none} .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} diff --git a/core/src/host/channel.js b/core/src/host/channel.js index f63af717a..776a09dba 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { questionReply } from './parts.js' import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' +import { isFaceRoom } from './room-membership.js' +import { mentionSpans } from '../shared/mentions.js' const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } @@ -356,7 +358,20 @@ export function install(rt) { const past = rt.pastOwners.get(body.sessionId) const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId const room = rt.roomOf(sessionId) - if (room && actor) { + if (room && isFaceRoom(room)) { + if (actor && rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') + // Face rooms must not go through sessionController.prompt: that starts a local + // bot round (turns/steps) and never fans the line to the other FlyBot instance. + const line = await rt.postRoomMessage(room.id, { text, displayName: actor?.displayName }, actor) + const who = line?.who || actor?.displayName || 'You' + if (typeof rt.postToRoom === 'function') { + await rt.postToRoom(room, { id: 'face-notice', name: who }, `${who}: ${text}`, { quiet: true, notice: true }) + .catch(error => rt.warn?.('ds-bot: face-room line not shown locally: %s', error)) + } + const bots = (room.members ?? []).map(id => rt.botOf(id)).filter(bot => bot?.name) + const named = mentionSpans(text, bots).some(span => span.bot) + if (!named) return ok(true) + } else if (room && actor) { if (rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') await rt.postRoomMessage(room.id, { text, displayName: actor.displayName }, actor) const agent = await rt.resolveAgent(sessionId) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 30af95e89..fc97efb48 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -97,6 +97,15 @@ export function humanOwnersOf(room, state) { return owners } +// Unsigned-in cloud UI has no member session. A face room still has one local owner +// (the person whose Bots joined); type as that human so the line can fan out. +export function implicitRoomOwner(room, state) { + const owners = humanOwnersOf(room, state) + const id = owners.size >= 1 ? [...owners][0] : Object.keys(state?.members || {})[0] + if (!id) return undefined + return { memberId: id, displayName: displayNameFor(state, id) || 'You' } +} + export function isCrossOwnerRoom(room, state) { return humanOwnersOf(room, state).size > 1 } @@ -636,19 +645,23 @@ export function install(rt) { const body = String(text ?? '').trim() if (body === '') throw invalid('Nothing to send') // Actor wins: a forged authorId in the body grants nothing. - const kind = actor ? 'human' : authorKind - const id = actor?.memberId ?? authorId + const implicit = (!actor && !authorId && isFaceRoom(room)) ? implicitRoomOwner(room, state) : undefined + const kind = actor ? 'human' : (authorKind || (implicit ? 'human' : authorKind)) + const id = actor?.memberId ?? authorId ?? implicit?.memberId if (!kind || !id) throw forbidden('Sign in first') if (rt.authzEnforced?.() && actor && !roomVisibleTo(state, actor, room.id)) { throw forbidden('You are not a member of that room') } if (Array.isArray(room.participants) || actor) { materialize(room) - if (!participantOf(room, kind, id)) throw forbidden('You are not a member of that room') + const here = participantOf(room, kind, id) + const faceOwner = isFaceRoom(room) && kind === 'human' + && (humanOwnersOf(room, state).has(id) || Object.hasOwn(state.members || {}, id)) + if (!here && !faceOwner) throw forbidden('You are not a member of that room') } const remote = kind === 'bot' ? currentParticipants(room).find(p => p.remote && (p.id === id || p.handle === id)) : undefined const who = kind === 'human' - ? displayNameFor(state, id, actor ? actor.displayName : displayName) + ? (actor?.displayName || displayName || implicit?.displayName || displayNameFor(state, id) || 'You') : (remote ? remoteBotTitle(remote) : (state.bots?.[id]?.name ?? displayName ?? id)) const line = append(room, rt.authoredRoomLine(room, { who, diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 46d72371b..e33d438e9 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -334,6 +334,7 @@ export function install(rt) { hop: source.hop ?? 0, ...(source.late ? { late: true } : {}), ...(source.quiet ? { quiet: true } : {}), + ...(source.notice ? { notice: true } : {}), ...(source.failed ? { failed: source.failed } : {}), ...(source.resumed ? { resumed: true } : {}), ...(source.again ? { again: true } : {}), diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index 2ad2b930f..7805dfec0 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -41,15 +41,18 @@ test('new-chat splits a friend\'s Bot out of local members into remote participa test('the sidebar + menu wires plugin actions in after Create group chat', () => { const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + const menu = src.indexOf('menuOnly') const group = src.indexOf("t('Create group chat')") const actions = src.indexOf('for (const action of newChatActionRows') - const agent = src.indexOf("t('Add DSH Agent')") + const agent = src.indexOf("t('Add Agent')") + assert.ok(menu > 0, 'sidebar + starts as a short action menu') assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) assert.match(src, /listedChatBots/) assert.match(src, /pickerBotLabel/) assert.match(src, /splitRoomPicks/) assert.match(src, /choosingAdmin \? bots.filter\(bot => !bot.remote\)/) assert.match(src, /t\(action\.label\)/) + assert.match(src, /data-menu/) const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') @@ -59,5 +62,6 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) assert.equal(DICTIONARY.zh['Add friend'], '添加好友') assert.equal(DICTIONARY.zh['Friend requests'], '好友请求') + assert.equal(DICTIONARY.zh['Add Agent'], '添加 Agent') assert.match(DICTIONARY.zh['Paste an invite link or code.'], /邀请/) }) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 016c18072..c8f535bde 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -11,6 +11,7 @@ import { install as installAuthz } from '../src/host/authz.js' import { install as installRooms, isFaceRoom, + implicitRoomOwner, linesVisibleTo, ownsInvitedBot, presentRoom, @@ -281,6 +282,12 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async assert.ok(shown.presentedParticipants.some(p => /Writer A/.test(p.label) && /Alice/.test(p.label))) await rt.postRoomMessage('face-1', { text: 'hello', authorId: 'spec-a', authorKind: 'bot' }) assert.equal(sent.length, 0) + const implicit = implicitRoomOwner(room, rt.state) + assert.equal(implicit?.memberId, 'owner-1') + const human = await rt.postRoomMessage('face-1', { text: 'hello from the owner' }) + assert.equal(human.authorKind, 'human') + assert.equal(human.authorId, 'owner-1') + assert.equal(human.text, 'hello from the owner') const line = rt.applyFaceRoomLine(room, { kind: 'joined', who: 'Room', text: "Bob's Coder joined; available to this room only", eventId: 'fev_1', eventCursor: 1 }) assert.equal(line.kind, 'joined') assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 495826dc0..42d660296 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -331,6 +331,38 @@ test('a stopped round leaves a waiting message for the next round', async (t) => assert.equal(inboxOf(room.id).nextTurn.length, 1) }) +test('a face-to-face send posts a human line and does not start a bot round', async (t) => { + const { call, stop, sent, ctx, rt } = start() + t.after(stop) + await team(call, sent) + await rt.load() + const prompts = [] + const orig = ctx.sessionController.prompt + ctx.sessionController.prompt = async (request) => { + prompts.push(request) + return orig(request) + } + const chief = (await call('state', {})).value.mainBotId + const hosted = await rt.upsertFaceRoom({ + roomId: 'frm_send', + localId: 'face-send', + status: 'open', + members: [{ handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }], + localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], + }) + sent.length = 0 + const result = await call('send', { sessionId: hosted.id, text: 'hello from the owner' }) + assert.equal(result.ok, true) + assert.equal(prompts.length, 0) + const line = hosted.log.find(item => item.text === 'hello from the owner') + assert.ok(line) + assert.equal(line.authorKind, 'human') + assert.equal(sent.some(entry => entry.message?.source?.role === 'room'), false) + const named = await call('send', { sessionId: hosted.id, text: '@Chief ping' }) + assert.equal(named.ok, true) + assert.equal(prompts.length, 1) +}) + 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) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 49e65b6ac..bc657d817 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -25,7 +25,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | --- | --- | | `core/src/client/` | Client sources → build to `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face group, Add friend, and Friend requests. | -| `core/test/new-chat.test.js` | Picker filters and the **+** menu order (Create group chat, then plugin actions, then Add DSH Agent). | +| `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, then Create group chat, plugin actions, then Add Agent). | ## Invariants @@ -76,6 +76,7 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send ### New-chat menu - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, next to Create group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. Social registers Face-to-face group, Add friend, and Friend requests; 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 **Create group chat**. The Agent row in that FlyBot menu is **Add Agent**; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126). ## Tests diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 4faf8c38d..4dea9149d 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -60,6 +60,10 @@ A tracked request with a stable id (`task-`) and a small state machine, own - Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. A leave envelope, hide, or grant revoke marks it left/hidden. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. +### Face-to-face owner send + +- Channel `send` on a face-to-face room (`origin: 'face-to-face'`) always `postRoomMessage`s, then shows a quiet local notice (`postToRoom` `quiet`+`notice`). It does not call `sessionController.prompt` unless a local Bot is @mentioned. Unsigned-in cloud UI uses `implicitRoomOwner` (the local human) so the line still fans out over `onFaceLine` / `FLYBOT-FACE-HUMAN` and the other FlyBot instance shows it; that path must not open a bot round (#124). Covered by `core/test/remote-room.test.js` and `core/test/rounds.test.js`. + ### Face-to-face participant look - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 4dbf7aa92..569af12a8 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -50,7 +50,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. -9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Slot `flybot.new-chat.action` adds a row to the sidebar **+** new-chat menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. +9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner, demoted Chief — `deleteBot`/`setMain` also invalidate eagerly), and the pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): a working task's held send calls `rt.taskHeld` (blocks on that approval); the owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. @@ -68,7 +68,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Face-to-face owner lines -- `onFaceLine` receives every appended room line, including the owner's typed human lines. The social plugin sends those through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the matching room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The envelope `who` is still taken from the JSON (a follow-up should bind the display name to the sending member record). Still not team mode. +- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The envelope `who` is still taken from the JSON (a follow-up should bind the display name to the sending member record). Still not team mode. ### Host Bot look for classic-script plugins diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 5b440517a..1b28f7b9b 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -30,7 +30,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, invites). | | `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/client.js` | Friends' Bots: badge, compact pending jump list, Remove this Bot, cached roster / Refreshing…, header without + Add; sidebar + Face-to-face group / Add friend / Friend requests (tile grid, 6-digit join, Enter chat + Leave footer), one-line face-room rows + Past rooms, in-chat Approve / Reject dock, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | +| `plugins/social/client.js` | Friends' Bots: badge, Remove this Bot, cached roster / Refreshing…, header without + Add; sidebar + Face-to-face group / Add friend / Friend requests (tile grid, 6-digit join, Enter chat + Leave footer), one-line face-room rows + Past rooms, in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid then Leave / Auto-send, Bot settings Invite / Manage access, Add friend mint-and-copy. | ## Entry points @@ -48,7 +48,13 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Inline approvals and compact Friends' Bots (#114, #115) - Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as an inline card in the open chat (`flybot.session.dock`): draft text, **Approve** / **Reject** for the owner, **Approve all** when several, and **Waiting for the owner's approval** with no buttons for anyone else. The owner's click is consent (`confirm_code` in the same `decide_approval` call). After a decision the card reads Approved or Rejected. The Friends' Bots "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is a single compact checkbox (tooltip only) on the Face-to-face rooms header and the open room's overview — not on each pending row or room card. -- Each Face-to-face room in Friends' Bots is one row: name, a small member-avatar stack (`+N`), a muted open/closed/left/`10m` chip, **Open** and **Leave** (and **Start** while matching). Closed and left rooms sit under **Past rooms**; with no live room the Face-to-face header and Auto-send are omitted. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The Friends' Bots header is title, Bot count, and the **N to approve** chip — no **+ Add**. **Add friend**, **Friend requests**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Friend requests expand Friends' Bots and show the same join / Accept / Decline panel. +- Each Face-to-face room in Friends' Bots is one compact row: name, a small member-avatar stack (`+N`), a muted open/closed/left/`10m` chip, click-to-open and **Leave** (no **Start** on the card). Closed and left rooms sit under **Past rooms** with **Open** only. With no live room the Face-to-face header and Auto-send are omitted. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The Friends' Bots header is title, Bot count, and the **N to approve** chip — no **+ Add** and no stacked approval list. **Add friend**, **Friend requests**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Friend requests expand Friends' Bots and show mint-and-copy plus join / Accept / Decline. + +### Face-to-face waiting grid, leave, and social + (#124, #125, #126) + +- Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: avatar above the Bot name, owner on a second line (`data-flybot=face-owner`). **Leave** and **Enter chat** share one footer row. Join copy uses owner + Bot name (`Chief · BoxA` / `BoxA's Chief`), never `BoxA's BoxA` or generic `BoxA's Bot`. Ended joining says **Joining has ended**, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). +- **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId` and have one primary action (**Open**); live rows are click-to-open with **Leave** only (no Open/Start/Leave triple in the narrow Friends' Bots column). +- Sidebar **+** is a short action menu, not the To:/composer. Add friend has one-click **Copy invite link** (mint + clipboard). Friends' Bots 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…`. ### Face-to-face two-instance rooms (#117) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1b0b76b29..10b580967 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -136,6 +136,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Settings pages': '设置页面', 'Models, providers, and the rest of DeepSeek Harness': '模型、服务商和 DeepSeek Harness 的其他设置', 'Harness settings': 'Harness 设置', +@@ -333,6 +333,7 @@ const ZH = { + // The DSH Agent's own row and Session list. "DSH Agent" is a product name and + // stays in English. + 'Add DSH Agent': '添加 DSH Agent', ++ 'Add Agent': '添加 Agent', + 'Remove DSH Agent': '移除 DSH Agent', + 'New session': '新会话', + Archive: '归档', @@ -344,13 +344,13 @@ const ZH = { '{n} d': '{n} 天', @@ -572,6 +580,14 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } +@@ -331,6 +331,7 @@ export function install(rt) { + hop: source.hop ?? 0, + ...(source.late ? { late: true } : {}), + ...(source.quiet ? { quiet: true } : {}), ++ ...(source.notice ? { notice: true } : {}), + ...(source.failed ? { failed: source.failed } : {}), + ...(source.resumed ? { resumed: true } : {}), + ...(source.again ? { again: true } : {}), @@ -363,7 +363,11 @@ export function install(rt) { if (rt.roomOf(id) && source.kind === 'user') { const images = (Array.isArray(event.data.content) ? event.data.content : []) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 252afb96a..25ef5ee53 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -426,7 +426,7 @@ diff --git a/src/client/index.js b/src/client/index.js diff --git a/src/client/new-chat.js b/src/client/new-chat.js --- a/src/client/new-chat.js +++ b/src/client/new-chat.js -@@ -6,6 +6,8 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' +@@ -6,11 +6,15 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' import { CreateBot } from './create-bot.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' import { t } from './i18n.js' @@ -435,6 +435,13 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js export function NewChat({ mode, roster, actions }) { const [query, setQuery] = useState('') + const [picked, setPicked] = useState([]) + const [group, setGroup] = useState(mode === 'group') ++ // Sidebar + starts as a short action menu; the To:/composer opens after New chat. ++ const [compose, setCompose] = useState(mode !== 'new') + // A new group chat picks its admin first, then the other members. + const [admin, setAdmin] = useState(null) + const [message, setMessage] = useState('') @@ -24,9 +24,11 @@ export function NewChat({ mode, roster, actions }) { useEscape(close) const paneLeft = usePaneLeft() @@ -463,7 +470,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js if (text) await actions.send(room.id, text) actions.openSession(room.id) close() -@@ -87,7 +87,12 @@ export function NewChat({ mode, roster, actions }) { +@@ -87,22 +87,40 @@ export function NewChat({ mode, roster, actions }) { close() return } @@ -477,10 +484,17 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js await actions.send(room.id, text) actions.openSession(room.id) close() -@@ -96,13 +96,22 @@ export function NewChat({ mode, roster, actions }) { + }) ++ const menuOnly = mode === 'new' && !compose && !group && picked.length === 0 && wanted === '' + const options = [] if (!group && picked.length === 0 && mode !== 'search' && wanted === '') { ++ if (menuOnly) { ++ options.push({ key: 'chat', icon: h(PlusIcon), label: t('New chat'), run: () => { setCompose(true); setCursor(0); setTimeout(() => inputRef.current?.focus()) } }) ++ } options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot }) - options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) +- options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) +- // A team holds at most one DSH Agent; the entry exists only while there is none. ++ options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCompose(true); setCursor(0); inputRef.current?.focus() } }) + for (const action of newChatActionRows({ mode, group, picked, query, roster })) { + options.push({ + key: action.key, @@ -490,9 +504,10 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + run: () => action.run({ close, actions, roster }), + }) + } - // A team holds at most one DSH Agent; the entry exists only while there is none. ++ // A team holds at most one Agent; the entry exists only while there is none. if ((roster.agents ?? []).length === 0) { - options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) +- options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) ++ options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } for (const bot of matches) { @@ -501,6 +516,32 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js } if (wanted !== '' && !exact && mode !== 'search') options.push({ key: 'create', icon: h(PlusIcon), label: t('Create new Bot “{name}”', { name: query.trim() }), run: createBot }) const active = Math.min(cursor, Math.max(options.length - 1, 0)) +@@ -127,8 +127,11 @@ export function NewChat({ mode, roster, actions }) { + else if (admin !== null) setAdmin(null) + } + return h('div', { className: 'bt-pane', style: { left: paneLeft } }, +- h('div', { className: 'bt-newchat', role: 'dialog', 'aria-label': mode === 'search' ? t('Search') : t('New chat') }, +- h('div', { className: 'bt-to' }, ++ h('div', { className: 'bt-newchat', role: 'dialog', 'aria-label': mode === 'search' ? t('Search') : t('New chat'), 'data-menu': menuOnly || undefined }, ++ menuOnly ? h('div', { className: 'bt-to' }, ++ h('span', { className: 'bt-to-label' }, t('New…')), ++ h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Close new chat'), onClick: close }, '×')) ++ : h('div', { className: 'bt-to' }, + h('span', { className: 'bt-to-label' }, t('To:')), + adminBot ? h('span', { key: adminBot.id, className: 'bt-token bt-token-admin', title: t('Admin') }, + h(BotMark, { bot: adminBot, size: 16 }), adminBot.name, h(AdminStar), +@@ -164,9 +164,9 @@ export function NewChat({ mode, roster, actions }) { + option.bot ? h(BotMark, { bot: option.bot, size: 22 }) : h('span', { className: 'bt-option-icon' }, option.icon), + h('span', { className: 'bt-option-label' }, option.label), + option.hint && index === active ? h('span', { className: 'bt-option-hint' }, option.hint) : null)))), +- h('div', { className: 'bt-nc-spacer' }), ++ menuOnly ? null : h('div', { className: 'bt-nc-spacer' }), + error ? h('div', { className: 'bt-error' }, error) : null, +- h('div', { className: 'bt-nc-compose' }, ++ menuOnly ? null : h('div', { className: 'bt-nc-compose' }, + h('div', { className: 'bt-nc-card' }, + h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), + h('textarea', { diff --git a/src/client/palette.js b/src/client/palette.js --- a/src/client/palette.js +++ b/src/client/palette.js @@ -889,6 +930,14 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} +@@ -267,6 +267,7 @@ button.bt-q-opt:active{background:var(--bt-active)} + @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} + + .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} ++.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:8px 12px;box-shadow:none} + .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} + .bt-to-label{color:var(--bt-ink-2)} + .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} @@ -782,6 +782,9 @@ 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} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 7172e652c..a127780d2 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -106,6 +106,15 @@ diff --git a/src/client/mark.js b/src/client/mark.js diff --git a/src/host/channel.js b/src/host/channel.js --- a/src/host/channel.js +++ b/src/host/channel.js +@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto' + import { createUserMessage } from '@deepseek-ai/dsh-llm' + import { questionReply } from './parts.js' + import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' ++import { isFaceRoom } from './room-membership.js' ++import { mentionSpans } from '../shared/mentions.js' + + const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } + @@ -28,7 +28,8 @@ export function install(rt) { // Models come from DSH, not state.json, so a provider added in Harness settings // changes the view without a new revision. @@ -305,7 +314,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js } case 'folders': case 'folder-pick': -@@ -278,9 +278,23 @@ export function install(rt) { +@@ -278,9 +278,36 @@ export function install(rt) { const text = String(body.text ?? '').trim() if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') await rt.load() @@ -315,7 +324,20 @@ diff --git a/src/host/channel.js b/src/host/channel.js const past = rt.pastOwners.get(body.sessionId) const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId + const room = rt.roomOf(sessionId) -+ if (room && actor) { ++ if (room && isFaceRoom(room)) { ++ if (actor && rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') ++ // Face rooms must not go through sessionController.prompt: that starts a local ++ // bot round (turns/steps) and never fans the line to the other FlyBot instance. ++ const line = await rt.postRoomMessage(room.id, { text, displayName: actor?.displayName }, actor) ++ const who = line?.who || actor?.displayName || 'You' ++ if (typeof rt.postToRoom === 'function') { ++ await rt.postToRoom(room, { id: 'face-notice', name: who }, `${who}: ${text}`, { quiet: true, notice: true }) ++ .catch(error => rt.warn?.('ds-bot: face-room line not shown locally: %s', error)) ++ } ++ const bots = (room.members ?? []).map(id => rt.botOf(id)).filter(bot => bot?.name) ++ const named = mentionSpans(text, bots).some(span => span.bot) ++ if (!named) return ok(true) ++ } else if (room && actor) { + if (rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden') + await rt.postRoomMessage(room.id, { text, displayName: actor.displayName }, actor) + const agent = await rt.resolveAgent(sessionId) @@ -520,3 +542,45 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js source, }) if (steer) agent.steer(message) +diff --git a/test/rounds.test.js b/test/rounds.test.js +--- a/test/rounds.test.js ++++ b/test/rounds.test.js +@@ -331,6 +331,38 @@ test('a stopped round leaves a waiting message for the next round', async (t) => + assert.equal(inboxOf(room.id).nextTurn.length, 1) + }) + ++test('a face-to-face send posts a human line and does not start a bot round', async (t) => { ++ const { call, stop, sent, ctx, rt } = start() ++ t.after(stop) ++ await team(call, sent) ++ await rt.load() ++ const prompts = [] ++ const orig = ctx.sessionController.prompt ++ ctx.sessionController.prompt = async (request) => { ++ prompts.push(request) ++ return orig(request) ++ } ++ const chief = (await call('state', {})).value.mainBotId ++ const hosted = await rt.upsertFaceRoom({ ++ roomId: 'frm_send', ++ localId: 'face-send', ++ status: 'open', ++ members: [{ handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }], ++ localBots: [{ id: chief, handle: '@chief.me.home', name: 'Chief' }], ++ }) ++ sent.length = 0 ++ const result = await call('send', { sessionId: hosted.id, text: 'hello from the owner' }) ++ assert.equal(result.ok, true) ++ assert.equal(prompts.length, 0) ++ const line = hosted.log.find(item => item.text === 'hello from the owner') ++ assert.ok(line) ++ assert.equal(line.authorKind, 'human') ++ assert.equal(sent.some(entry => entry.message?.source?.role === 'room'), false) ++ const named = await call('send', { sessionId: hosted.id, text: '@Chief ping' }) ++ assert.equal(named.ok, true) ++ assert.equal(prompts.length, 1) ++}) ++ + 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) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 7eff7630c..70bcaf334 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -87,6 +87,14 @@ diff --git a/client.js b/client.js "Settings pages": "设置页面", "Models, providers, and the rest of DeepSeek Harness": "模型、服务商和 DeepSeek Harness 的其他设置", "Harness settings": "Harness 设置", +@@ -394,6 +394,7 @@ var ZH = { + // The DSH Agent's own row and Session list. "DSH Agent" is a product name and + // stays in English. + "Add DSH Agent": "添加 DSH Agent", ++ "Add Agent": "添加 Agent", + "Remove DSH Agent": "移除 DSH Agent", + "New session": "新会话", + Archive: "归档", @@ -404,13 +404,13 @@ var ZH = { "{n} h": "{n} 小时", "{n} d": "{n} 天", @@ -1151,6 +1159,14 @@ diff --git a/client.js b/client.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} +@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} + @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} + + .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} ++.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:8px 12px;box-shadow:none} + .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} + .bt-to-label{color:var(--bt-ink-2)} + .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} @@ -2599,6 +2599,9 @@ 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} @@ -1161,10 +1177,12 @@ diff --git a/client.js b/client.js .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} .bt-event>span{padding:0 2px} -@@ -2821,260 +2821,142 @@ function compileFragmentSelectors(text, names) { +@@ -2819,262 +2819,144 @@ function compileFragmentSelectors(text, names) { + }); + } function installCompiledStyles(text, tag) { - const style = installStyles("", tag); - const cache = /* @__PURE__ */ new WeakMap(); +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); - const render = () => { - const names = /* @__PURE__ */ new Set(); - for (const sheet of document.styleSheets) { @@ -1324,6 +1342,8 @@ diff --git a/client.js b/client.js - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ const style = installStyles("", tag); ++ const cache = /* @__PURE__ */ new WeakMap(); + const render = () => { + const names = /* @__PURE__ */ new Set(); + for (const sheet of document.styleSheets) { @@ -2435,8 +2455,7 @@ diff --git a/client.js b/client.js bot.name, - room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react7.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); +} @@ -2471,7 +2490,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + name, + (0, import_react7.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react7.createElement)(GlobeMark), handle) -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); @@ -6243,7 +6263,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,62 +8284,64 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,62 +8284,65 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -6316,6 +6336,7 @@ diff --git a/client.js b/client.js + const [query, setQuery] = (0, import_react21.useState)(""); + const [picked, setPicked] = (0, import_react21.useState)([]); + const [group, setGroup] = (0, import_react21.useState)(mode === "group"); ++ const [compose, setCompose] = (0, import_react21.useState)(mode !== "new"); + const [admin, setAdmin] = (0, import_react21.useState)(null); + const [message, setMessage] = (0, import_react21.useState)(""); + const [busy, setBusy] = (0, import_react21.useState)(false); @@ -6351,7 +6372,7 @@ diff --git a/client.js b/client.js if (text) await actions.send(room2.id, text); actions.openSession(room2.id); close(); -@@ -8411,42 +8411,56 @@ function NewChat({ mode, roster, actions }) { +@@ -8411,42 +8411,65 @@ function NewChat({ mode, roster, actions }) { close(); return; } @@ -6366,13 +6387,22 @@ diff --git a/client.js b/client.js actions.openSession(room2.id); close(); }); ++ const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === ""; const options = []; if (!group && picked.length === 0 && mode !== "search" && wanted === "") { - options.push({ key: "new", icon: (0, import_react20.createElement)(PlusIcon), label: t("Create new Bot"), run: createBot }); - options.push({ key: "group", icon: (0, import_react20.createElement)(PeopleIcon), label: t("Create group chat"), run: () => { ++ if (menuOnly) { ++ options.push({ key: "chat", icon: (0, import_react21.createElement)(PlusIcon), label: t("New chat"), run: () => { ++ setCompose(true); ++ setCursor(0); ++ setTimeout(() => inputRef.current?.focus()); ++ } }); ++ } + options.push({ key: "new", icon: (0, import_react21.createElement)(PlusIcon), label: t("Create new Bot"), run: createBot }); + options.push({ key: "group", icon: (0, import_react21.createElement)(PeopleIcon), label: t("Create group chat"), run: () => { setGroup(true); ++ setCompose(true); setCursor(0); inputRef.current?.focus(); } }); @@ -6387,7 +6417,7 @@ diff --git a/client.js b/client.js + } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { -+ options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { ++ options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Agent"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -6417,7 +6447,7 @@ diff --git a/client.js b/client.js roster, actions, start: draft, -@@ -8467,33 +8467,33 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +8467,38 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -6428,9 +6458,15 @@ diff --git a/client.js b/client.js - (0, import_react20.createElement)( + (0, import_react21.createElement)( "div", - { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat") }, +- { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat") }, - (0, import_react20.createElement)( -+ (0, import_react21.createElement)( ++ { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat"), "data-menu": menuOnly || void 0 }, ++ menuOnly ? (0, import_react21.createElement)( ++ "div", ++ { className: "bt-to" }, ++ (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("New…")), ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") ++ ) : (0, import_react21.createElement)( "div", { className: "bt-to" }, - (0, import_react20.createElement)("span", { className: "bt-to-label" }, t("To:")), @@ -6505,9 +6541,9 @@ diff --git a/client.js b/client.js - (0, import_react20.createElement)("div", { className: "bt-nc-spacer" }), - error ? (0, import_react20.createElement)("div", { className: "bt-error" }, error) : null, - (0, import_react20.createElement)( -+ (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), ++ menuOnly ? null : (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), + error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, -+ (0, import_react21.createElement)( ++ menuOnly ? null : (0, import_react21.createElement)( "div", { className: "bt-nc-compose" }, - (0, import_react20.createElement)( @@ -6712,7 +6748,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,50 +8875,50 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,69 +8875,85 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -6780,7 +6816,14 @@ diff --git a/client.js b/client.js var ROOM_MODES = [ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."], ["everyone", "Everyone", "Every member answers in turn, admin first."] -@@ -8929,15 +8929,27 @@ function roomSubtitle(room2, roster) { + ]; + var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; + function roomSubtitle(room2, roster) { ++ if (room2.origin === "face-to-face") { ++ const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.leftSeq === void 0); ++ return t("{count} members", { count: live.length }); ++ } + const admin = roster.byId[room2.admin]; return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); } function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { @@ -6967,7 +7010,10 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)(BotMark, { bot, size: 22 }), + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, bot.name)), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add")) -+ )), + )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( + ...outsideRemotes.map((bot) => (0, import_react24.createElement)( + "button", + { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -6977,10 +7023,7 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) - )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( ++ )), + (0, import_react24.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) + ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react24.createElement)(PlusIcon), t("Add member")) : null, + (0, import_react24.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react24.createElement)( @@ -9046,8 +9089,8 @@ diff --git a/client.js b/client.js + room2.sharedFrom.invitedAt ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, + (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) + ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), -+ (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), -+ (0, import_react32.createElement)( ++ face ? null : (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), ++ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : (0, import_react32.createElement)( "div", { key: "members" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length })), @@ -9116,7 +9159,7 @@ diff --git a/client.js b/client.js + ]) ), - (0, import_react31.createElement)( -+ (0, import_react32.createElement)( ++ face ? null : (0, import_react32.createElement)( "div", { key: "mode" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Who answers")), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 69dcbff7a..d97f545ab 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -66,7 +66,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline -@@ -113,16 +113,82 @@ function About({ text, title = 'About' }) { +@@ -113,16 +113,84 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. @@ -121,6 +121,9 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - room.notice - ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) - : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), +- h('div', { key: 'members' }, +- h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length })), +- h('div', { className: 'bt-members', role: 'list' }, members.map((member) => { + h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), + invited && room.sharedFrom && !face + ? h('div', { key: 'from', className: 'bt-shared-from' }, @@ -133,10 +136,10 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + : room.notice + ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) + : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), -+ h(SharedRoomApprovals, { key: 'approvals', room }), - h('div', { key: 'members' }, -- h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length })), -- h('div', { className: 'bt-members', role: 'list' }, members.map((member) => { ++ face ? null : h(SharedRoomApprovals, { key: 'approvals', room }), ++ face && (room.face?.status === 'candidate' || room.face?.status === 'joining') ++ ? null ++ : h('div', { key: 'members' }, + h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length + humans.length + remotes.length })), + h('div', { className: 'bt-members', role: 'list' }, [ + ...humans.map(person => h('div', { key: person.id, role: 'listitem' }, @@ -154,15 +157,17 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js const entry = entries.find(item => item.id === member.id) return h('div', { key: member.id, role: 'listitem' }, h('div', { className: 'bt-member' }, -@@ -131,7 +131,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) +@@ -131,8 +131,8 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) h('span', null, member.name)), room.admin === member.id ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null), entry ? h(MemberWork, { entry, bot: member, room, roster, actions, now }) : null) - }))), +- h('div', { key: 'mode' }, h('div', { className: 'bt-section-title' }, t('Who answers')), + })])), - h('div', { key: 'mode' }, h('div', { className: 'bt-section-title' }, t('Who answers')), ++ face ? null : h('div', { key: 'mode' }, h('div', { className: 'bt-section-title' }, t('Who answers')), h('div', { className: 'bt-kv' }, h('span', null, t(ROOM_MODES.find(([id]) => id === shownMode(room, roster))?.[1] ?? 'Everyone')))), usage ? h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')), + h('div', { className: 'bt-kv' }, h('span', null, room.name), h('span', null, usage))) : null, diff --git a/src/client/group-settings.js b/src/client/group-settings.js --- a/src/client/group-settings.js +++ b/src/client/group-settings.js @@ -180,6 +185,17 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], +@@ -16,6 +16,10 @@ export const ROOM_MODES = [ + export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone') + + export function roomSubtitle(room, roster) { ++ if (room.origin === 'face-to-face') { ++ const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.leftSeq === undefined) ++ return t('{count} members', { count: live.length }) ++ } + const admin = roster.byId[room.admin] + return [t('{count} members', { count: room.members.length }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ') + } @@ -31,8 +31,20 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 30af95e89..fc97efb48 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -97,6 +97,15 @@ export function humanOwnersOf(room, state) { return owners } +// Unsigned-in cloud UI has no member session. A face room still has one local owner +// (the person whose Bots joined); type as that human so the line can fan out. +export function implicitRoomOwner(room, state) { + const owners = humanOwnersOf(room, state) + const id = owners.size >= 1 ? [...owners][0] : Object.keys(state?.members || {})[0] + if (!id) return undefined + return { memberId: id, displayName: displayNameFor(state, id) || 'You' } +} + export function isCrossOwnerRoom(room, state) { return humanOwnersOf(room, state).size > 1 } @@ -636,19 +645,23 @@ export function install(rt) { const body = String(text ?? '').trim() if (body === '') throw invalid('Nothing to send') // Actor wins: a forged authorId in the body grants nothing. - const kind = actor ? 'human' : authorKind - const id = actor?.memberId ?? authorId + const implicit = (!actor && !authorId && isFaceRoom(room)) ? implicitRoomOwner(room, state) : undefined + const kind = actor ? 'human' : (authorKind || (implicit ? 'human' : authorKind)) + const id = actor?.memberId ?? authorId ?? implicit?.memberId if (!kind || !id) throw forbidden('Sign in first') if (rt.authzEnforced?.() && actor && !roomVisibleTo(state, actor, room.id)) { throw forbidden('You are not a member of that room') } if (Array.isArray(room.participants) || actor) { materialize(room) - if (!participantOf(room, kind, id)) throw forbidden('You are not a member of that room') + const here = participantOf(room, kind, id) + const faceOwner = isFaceRoom(room) && kind === 'human' + && (humanOwnersOf(room, state).has(id) || Object.hasOwn(state.members || {}, id)) + if (!here && !faceOwner) throw forbidden('You are not a member of that room') } const remote = kind === 'bot' ? currentParticipants(room).find(p => p.remote && (p.id === id || p.handle === id)) : undefined const who = kind === 'human' - ? displayNameFor(state, id, actor ? actor.displayName : displayName) + ? (actor?.displayName || displayName || implicit?.displayName || displayNameFor(state, id) || 'You') : (remote ? remoteBotTitle(remote) : (state.bots?.[id]?.name ?? displayName ?? id)) const line = append(room, rt.authoredRoomLine(room, { who, diff --git a/patches/ds-bot/files/test/new-chat.test.js b/patches/ds-bot/files/test/new-chat.test.js index 2ad2b930f..7805dfec0 100644 --- a/patches/ds-bot/files/test/new-chat.test.js +++ b/patches/ds-bot/files/test/new-chat.test.js @@ -41,15 +41,18 @@ test('new-chat splits a friend\'s Bot out of local members into remote participa test('the sidebar + menu wires plugin actions in after Create group chat', () => { const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + const menu = src.indexOf('menuOnly') const group = src.indexOf("t('Create group chat')") const actions = src.indexOf('for (const action of newChatActionRows') - const agent = src.indexOf("t('Add DSH Agent')") + const agent = src.indexOf("t('Add Agent')") + assert.ok(menu > 0, 'sidebar + starts as a short action menu') assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) assert.match(src, /listedChatBots/) assert.match(src, /pickerBotLabel/) assert.match(src, /splitRoomPicks/) assert.match(src, /choosingAdmin \? bots.filter\(bot => !bot.remote\)/) assert.match(src, /t\(action\.label\)/) + assert.match(src, /data-menu/) const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') @@ -59,5 +62,6 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) assert.equal(DICTIONARY.zh['Add friend'], '添加好友') assert.equal(DICTIONARY.zh['Friend requests'], '好友请求') + assert.equal(DICTIONARY.zh['Add Agent'], '添加 Agent') assert.match(DICTIONARY.zh['Paste an invite link or code.'], /邀请/) }) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 016c18072..c8f535bde 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -11,6 +11,7 @@ import { install as installAuthz } from '../src/host/authz.js' import { install as installRooms, isFaceRoom, + implicitRoomOwner, linesVisibleTo, ownsInvitedBot, presentRoom, @@ -281,6 +282,12 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async assert.ok(shown.presentedParticipants.some(p => /Writer A/.test(p.label) && /Alice/.test(p.label))) await rt.postRoomMessage('face-1', { text: 'hello', authorId: 'spec-a', authorKind: 'bot' }) assert.equal(sent.length, 0) + const implicit = implicitRoomOwner(room, rt.state) + assert.equal(implicit?.memberId, 'owner-1') + const human = await rt.postRoomMessage('face-1', { text: 'hello from the owner' }) + assert.equal(human.authorKind, 'human') + assert.equal(human.authorId, 'owner-1') + assert.equal(human.text, 'hello from the owner') const line = rt.applyFaceRoomLine(room, { kind: 'joined', who: 'Room', text: "Bob's Coder joined; available to this room only", eventId: 'fev_1', eventCursor: 1 }) assert.equal(line.kind, 'joined') assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 9749afad6..f1f03e108 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -41,7 +41,8 @@ "src/host/channel.js", "src/host/delivery.js", "src/client/characters.js", - "src/client/mark.js" + "src/client/mark.js", + "test/rounds.test.js" ], "0007-version-and-tooling": [ "package.json", diff --git a/plugins/social/README.md b/plugins/social/README.md index 8c79d5832..73a3c913f 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -60,7 +60,7 @@ People in the same place (or on a call) put their Bots in one room by each typin 1. Click **+** at the top of the sidebar (next to Search) and choose **Face-to-face group**. 2. Choose which of your Bots to bring (tap tiles; default: the Main Bot). Each Bot shows the same avatar as in the sidebar. Type the same six digits as the people next to you. Click **Join**. 3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room is a wrapping grid of who is in (each Bot's avatar on top and name below; yours with a tiny accent dot). The countdown stays at the top; **Leave** and **Enter chat** share the footer. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke. -4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. The matching window is 10 minutes; when it ends or the room starts, the number is invalid. +4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. Joining stays open for 10 minutes; when it ends or the room starts, the number is invalid. 5. **Leave** removes your Bots in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. Bot replies in that room wait for your approval the same way friend replies do (`N to approve` / Waiting for your approval). Approve or Reject them there; they are not sent until you approve. The room header has **Auto-send my Bot's room messages** (also **Turn on auto-send** / **Approve and always auto-send in this room** on a waiting draft): one click, and your Bots post in rooms without asking each time. The same toggle turns it off. The setting is for this owner on Bot Social (`auto_approve_face_rooms`), not one room only. @@ -74,8 +74,8 @@ Invites are **per Bot**; there is no whole-home invite. A friend who joins an in The **Friends' Bots** section (collapsible header with a Bot count, ds-bot `--bt-*` tokens) lists friends' Bots by home, with an empty state. It paints a cached roster immediately and shows **Refreshing…** while it talks to Bot Social. The header is title, count, and the **N to approve** chip — there is no **+ Add** on it. **Face-to-face group**, **Add friend**, and **Friend requests** are on the sidebar **+** menu (next to Create group chat, slot `flybot.new-chat.action`): - **Face-to-face group:** pick Bots, 6-digit number, waiting room, Enter chat. -- **Add friend** / **Friend requests:** expand Friends' Bots and show the join panel. **Paste a friend's invite link or code** → **Join** sends a friend request from one of your Bots (**Join as**, default: the Main Bot). The friendship is between the invited Bot and that Bot only: your friend sees and reaches just it, and only it can message their Bot (the roster's `via`; other local Bots are refused before anything leaves). If the server also needs your own approval, the click counts as that approval. Incoming requests have **Accept** / **Decline**; requests you sent show under *Waiting for them to accept*. -- **Invite a friend to one of your Bots:** right-click the Bot (or Bot settings **Invite to this Bot**) and **Create invite link**. It works once and lasts 7 days. **Copy link** copies only the bare URL; **Copy invite with instructions** copies a short message naming the Bot ("Add my bot @chief. (Chief) from my FlyBot home . In FlyBot: open the + menu, choose Add friend, paste this link and click Join. Using a Grok bot? Send it this link and say 'join this FlyBot invite'. Link: (single use, expires )"). Each clipboard write happens inside the click. +- **Add friend** / **Friend requests:** expand Friends' Bots and show the join panel. **Copy invite link** mints a one-use invite for the selected Bot and copies it. **Paste a friend's invite link or code** → **Join** sends a friend request from one of your Bots (**Join as**, default: the Main Bot). The friendship is between the invited Bot and that Bot only: your friend sees and reaches just it, and only it can message their Bot (the roster's `via`; other local Bots are refused before anything leaves). If the server also needs your own approval, the click counts as that approval. Incoming requests have **Accept** / **Decline**; requests you sent show under *Waiting for them to accept*. +- **Invite a friend to one of your Bots:** **Copy invite link** on Add friend, or right-click the Bot (Bot settings **Invite to this Bot**) and **Create invite link**. It works once and lasts 7 days. **Copy link** copies only the bare URL; **Copy invite with instructions** copies a short message naming the Bot ("Add my bot @chief. (Chief) from my FlyBot home . In FlyBot: open the + menu, choose Add friend, paste this link and click Join. Using a Grok bot? Send it this link and say 'join this FlyBot invite'. Link: (single use, expires )"). Each clipboard write happens inside the click. Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`). diff --git a/plugins/social/client.js b/plugins/social/client.js index 501a8986d..bc4a326ed 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -96,9 +96,11 @@ window.__ModuleLoader__.load({ .fs-digits{flex:1 1 auto;min-width:0;width:0;height:30px;padding:0 9px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:16px;letter-spacing:.24em;font-variant-numeric:tabular-nums;outline:none;text-align:center} .fs-digits:focus{border-color:var(--bt-accent,#4d6bfe)} .fs-face{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px;min-width:0} -.fs-face-item{display:flex;flex-direction:column;gap:2px;min-width:0;width:100%} -.fs-face-row{display:flex;align-items:center;gap:6px;min-width:0;width:100%;padding:2px 0;flex-wrap:nowrap} +.fs-face-item{display:flex;flex-direction:column;gap:2px;min-width:0;width:100%;overflow:hidden} +.fs-face-row{display:flex;align-items:center;gap:6px;min-width:0;width:100%;padding:2px 0;flex-wrap:nowrap;overflow:hidden} .fs-face-row .fs-name{flex:1 1 0;min-width:0} +.fs-face-row[data-open]{cursor:pointer} +.fs-face-actions{display:flex;gap:4px;flex:none;flex-wrap:nowrap} .fs-face-actions .fs-btn-sm{height:22px;padding:0 6px;font-size:11px} .fs-stack{display:flex;flex-direction:row;align-items:center;flex:none} .fs-stack .fs-face-mark,.fs-stack [data-flybot=face-card-member]{margin-left:-6px} @@ -106,7 +108,6 @@ window.__ModuleLoader__.load({ .fs-stack [data-flybot=face-card-member]{display:inline-flex;border-radius:50%;box-shadow:0 0 0 2px var(--bt-card,#fff)} .fs-stack-more{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));margin-left:4px} .fs-chip{flex:none;font-size:11px;line-height:14px;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-tag-ink,rgba(20,20,20,.56));white-space:nowrap} -.fs-face-actions{display:flex;gap:4px;flex:none;flex-wrap:nowrap} .fs-face-head{display:flex;align-items:center;gap:8px;justify-content:space-between} .fs-face-head .fs-auto{margin-left:auto} .fs-past{margin:2px 0 4px} @@ -140,6 +141,9 @@ window.__ModuleLoader__.load({ .fs-tile-hit:hover,.fs-tile-on{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-tile-mark{position:relative;flex:none;display:inline-flex} .fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink,#141414)} +.fs-tile-owner{display:block;width:100%;font-size:11px;line-height:14px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.fs-wait-overview{margin:0 0 8px;padding:8px 10px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:8px;min-width:0;max-width:100%} +.fs-wait-overview .fs-wait-foot{margin-top:2px} .fs-tile-mine{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;background:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px var(--bt-card,#fff);pointer-events:none} .fs-tile-on .fs-face-mark{border-radius:50%;box-shadow:0 0 0 2px var(--bt-accent,#4d6bfe)} .fs-tile-check{position:absolute;right:-2px;bottom:-2px;width:16px;height:16px;border-radius:50%;background:var(--bt-accent,#4d6bfe);color:#fff;font-size:11px;line-height:16px;text-align:center;pointer-events:none;box-shadow:0 0 0 2px var(--bt-card,#fff)} @@ -251,16 +255,27 @@ window.__ModuleLoader__.load({ }, inner) } - function faceTileName(member) { - const label = String(member?.label ?? '').trim() - if (label) return label - const name = String(member?.displayName || member?.name || member?.handle || 'Bot').trim() - const owner = String(member?.ownerName ?? '').trim() - if (owner && name) return `${name} · ${owner}` - return name + function faceTileParts(member) { + const owner = String(member?.ownerName ?? member?.owner_name ?? '').trim() + const raw = String(member?.displayName || member?.name || '').trim() + let name = raw + if (!name || /^bot$/i.test(name) || (owner && name.toLowerCase() === owner.toLowerCase())) { + const fromLabel = String(member?.label ?? '').split('·')[0].replace(/'s Chief$/i, 'Chief').trim() + if (fromLabel && !/^bot$/i.test(fromLabel) && (!owner || fromLabel.toLowerCase() !== owner.toLowerCase())) name = fromLabel + else { + const part = String(member?.handle ?? '').replace(/^@/, '').split('.')[0] + name = part && part !== 'home' && part !== 'bot' + ? part.charAt(0).toUpperCase() + part.slice(1) + : (raw || 'Bot') + } + } + if (/^chief$/i.test(name)) name = 'Chief' + return { name: name || 'Bot', owner } } - function FaceTile({ bot, name, handle, mine, pick, selected, onToggle } = {}) { + function FaceTile({ bot, name, owner, member, handle, mine, pick, selected, onToggle } = {}) { + const parts = member ? faceTileParts(member) : { name: name || 'Bot', owner: owner || '' } + const title = [parts.name, parts.owner].filter(Boolean).join(' · ') const mark = h('span', { className: 'fs-tile-mark' }, h(FaceAvatar, { bot, size: 48 }), mine @@ -269,7 +284,10 @@ window.__ModuleLoader__.load({ pick && selected ? h('span', { className: 'fs-tile-check', 'data-flybot': 'face-pick-check', 'aria-hidden': true }, '✓') : null) - const caption = h('span', { className: 'fs-tile-name', title: name }, name) + const caption = [ + h('span', { key: 'n', className: 'fs-tile-name', title }, parts.name), + parts.owner ? h('span', { key: 'o', className: 'fs-tile-owner', 'data-flybot': 'face-owner', title: parts.owner }, parts.owner) : null, + ] if (pick) { return h('li', { className: selected ? 'fs-tile fs-tile-on' : 'fs-tile' }, h('label', { className: 'fs-tile-hit' }, @@ -277,7 +295,7 @@ window.__ModuleLoader__.load({ type: 'checkbox', className: 'fs-sr', checked: !!selected, - 'aria-label': name, + 'aria-label': title, onChange: onToggle, }), mark, @@ -288,7 +306,7 @@ window.__ModuleLoader__.load({ 'data-flybot': 'face-member', 'data-mine': mine || undefined, 'data-handle': handle, - title: name, + title, }, mark, caption) } @@ -328,8 +346,16 @@ window.__ModuleLoader__.load({ } const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim() + const looksLikeBotId = (value) => /^bot_[a-z0-9]+$/i.test(String(value ?? '').trim()) + function readableParty(value, fallback = '') { + const text = String(value ?? '').trim() + if (!text || looksLikeBotId(text)) return fallback + return text + } function pendingTitle(item) { - return item.from ? `${item.from} → ${item.toName || item.to || 'friend'}` : (item.toName || item.to || 'Outbound reply') + const from = readableParty(item.from, 'Bot') + const to = readableParty(item.toName) || (item.kind === 'face_room_message' ? 'the room' : readableParty(item.to, 'friend')) + return item.from || item.to || item.toName ? `${from} → ${to}` : (to || 'Outbound reply') } // Compact jump list. Approve / Reject live on the in-chat card. @@ -502,14 +528,14 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: () => onClose?.() }, 'Close')), waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, h('div', { className: 'fs-hint fs-wait-count', 'data-flybot': 'face-wait-count', 'data-tick': tick }, candidate - ? (room.expiresAt ? countdown(room.expiresAt) : 'Matching window open. Anyone with the number can still join.') + ? (room.expiresAt ? countdown(room.expiresAt) : 'Anyone with the number can still join.') : 'The room is open. The number is no longer valid.'), h('div', { className: 'fs-wait-body' }, h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, members.map(member => h(FaceTile, { key: member.handle || member.id, bot: faceLook({ roster, remoteChats, bots, member }), - name: faceTileName(member), + member, handle: member.handle, mine: memberIsMine(member, bots, room), }))), @@ -566,7 +592,7 @@ window.__ModuleLoader__.load({ } return 'open' } - function FaceRoomCard({ room, actions, onChange, roster, remoteChats } = {}) { + function FaceRoomCard({ room, actions, onChange, roster, remoteChats, past } = {}) { const [busy, setBusy] = useState('') const [note, setNote] = useState(null) const run = async (label, fn) => { @@ -581,22 +607,26 @@ window.__ModuleLoader__.load({ const members = room.members ?? [] const shown = members.slice(0, 3) const extra = Math.max(0, members.length - shown.length) - const candidate = room.status === 'candidate' const look = (m) => (m.remote === false || m.id && roster?.byId?.[m.id] ? faceLook({ roster, local: roster?.byId?.[m.id] || { id: m.id, name: m.displayName } }) : faceLook({ roster, remoteChats, member: m })) - return h('div', { className: 'fs-face-item', 'data-flybot': 'face-room', 'data-status': room.status }, - h('div', { className: 'fs-face-row' }, + const open = () => { if (room.roomId) actions?.openSession?.(room.roomId) } + return h('div', { className: 'fs-face-item', 'data-flybot': 'face-room', 'data-status': room.status, 'data-past': past || undefined }, + h('div', { + className: 'fs-face-row', + 'data-open': room.roomId ? true : undefined, + onClick: room.roomId ? open : undefined, + }, h('span', { className: 'fs-stack', 'aria-label': 'Members' }, shown.map(m => h('span', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle }, h(FaceAvatar, { bot: look(m), size: 20 }))), extra ? h('span', { className: 'fs-stack-more' }, `+${extra}`) : null), h('span', { className: 'fs-name' }, room.name || 'Face-to-face room'), h('span', { className: 'fs-chip', 'data-flybot': 'face-status' }, faceChip(room)), - h('span', { className: 'fs-face-actions' }, - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy || !room.roomId, onClick: () => { if (room.roomId) actions?.openSession?.(room.roomId) } }, 'Open'), - candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Start') : null, - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Leave'))), + h('span', { className: 'fs-face-actions', onClick: event => event.stopPropagation() }, + past + ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy || !room.roomId, onClick: open }, 'Open') + : h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Leave'))), note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } @@ -613,9 +643,13 @@ window.__ModuleLoader__.load({ return h('div', { className: 'fs-pending fs-chat-approval', 'data-flybot': 'chat-approval', 'data-role': 'other', role: 'status' }, h('div', { className: 'fs-name' }, WAITING_OTHERS)) } + const from = readableParty(item.from, 'Bot') + const to = readableParty(item.toName) || (item.kind === 'face_room_message' ? 'the room' : readableParty(item.to, 'friend')) return h('div', { className: 'fs-pending fs-chat-approval', 'data-flybot': 'chat-approval', 'data-role': 'owner', role: 'region', 'aria-label': WAITING_OWNER }, h('div', { className: 'fs-label' }, WAITING_OWNER), - item.from ? h('div', { className: 'fs-sub' }, `${item.from} → ${item.toName || item.to || 'friend'}`) : null, + item.from || item.to || item.toName || item.kind === 'face_room_message' + ? h('div', { className: 'fs-sub' }, `${from} → ${to}`) + : null, item.text ? h('div', { className: 'fs-draft' }, item.text) : (item.summary ? h('div', { className: 'fs-sub fs-msg' }, item.summary) : null), h('div', { className: 'fs-actions fs-chat-actions' }, h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => onDecide?.(item, 'approve') }, 'Approve'), @@ -626,11 +660,13 @@ window.__ModuleLoader__.load({ const list = Array.isArray(items) ? items : [] const handle = bot?.remote?.handle const faceId = room?.face?.roomId + const face = room?.origin === 'face-to-face' || Boolean(faceId) const handles = new Set((room?.participants ?? []).map(p => p.handle).filter(Boolean)) if (handle) handles.add(handle) return list.filter(item => { if (faceId && item.roomId === faceId) return true if (room?.id && item.roomId === room.id) return true + if (face && item.kind === 'face_room_message' && !item.roomId) return true if (item.to && handles.has(item.to)) return true if (sessionId && item.sessionId === sessionId) return true return false @@ -736,9 +772,9 @@ window.__ModuleLoader__.load({ function countdown(expiresAt) { const at = Date.parse(expiresAt) - if (!Number.isFinite(at)) return 'Matching window open' + if (!Number.isFinite(at)) return 'Anyone with the number can still join' const left = Math.max(0, at - Date.now()) - if (left <= 0) return 'The matching window has ended' + if (left <= 0) return 'Joining has ended' const minutes = Math.ceil(left / 60_000) if (minutes >= 2) return `${minutes} minutes left to join` const seconds = Math.max(1, Math.ceil(left / 1000)) @@ -746,14 +782,28 @@ window.__ModuleLoader__.load({ } const isPastFace = (room) => room?.status === 'closed' || room?.status === 'left' + function uniqueFaceRooms(list) { + const seen = new Set() + const out = [] + for (const room of Array.isArray(list) ? list : []) { + const id = room?.faceRoomId || room?.roomId + if (id) { + if (seen.has(id)) continue + seen.add(id) + } + out.push(room) + } + return out + } function FaceRooms({ actions, enabled = true, roster, remoteChats } = {}) { const [rooms, setRooms] = useState([]) const [autoSend, setAutoSend] = useState(false) const [autoBusy, setAutoBusy] = useState(false) const [autoError, setAutoError] = useState('') const [pastOpen, setPastOpen] = useState(false) - const live = rooms.filter(room => !isPastFace(room)) - const past = rooms.filter(isPastFace) + const unique = uniqueFaceRooms(rooms) + const live = unique.filter(room => !isPastFace(room)) + const past = unique.filter(isPastFace) const load = (poll = true) => call('social/face-rooms', { poll }).then(value => { setRooms(Array.isArray(value?.rooms) ? value.rooms : []) if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) @@ -779,49 +829,94 @@ window.__ModuleLoader__.load({ } catch (e) { setAutoError(errorText(e)) } finally { setAutoBusy(false) } } - if (!rooms.length) return null - const card = (room) => h(FaceRoomCard, { - key: room.faceRoomId || room.roomId, room, actions, onChange: () => load(true), roster, remoteChats, + if (!unique.length) return null + const card = (room, folded) => h(FaceRoomCard, { + key: room.faceRoomId || room.roomId, room, past: folded, actions, onChange: () => load(true), roster, remoteChats, }) const pastFold = past.length ? h('div', { className: 'fs-past', 'data-flybot': 'face-past' }, h('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: () => setPastOpen(open => !open) }, h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), 'Past rooms', h('span', { className: 'fs-count' }, String(past.length))), - pastOpen ? past.map(card) : null) : null + pastOpen ? past.map(room => card(room, true)) : null) : null return h('div', { 'data-flybot': 'face-rooms' }, live.length ? h('div', { className: 'fs-label fs-face-head' }, 'Face-to-face rooms', h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: toggle, error: autoError })) : null, - live.map(card), + live.map(room => card(room, false)), pastFold) } - function FaceRoomOverview({ room } = {}) { + function FaceRoomOverview({ room, roster, actions } = {}) { useStyles() const face = room?.origin === 'face-to-face' ? room.face : undefined if (room?.origin !== 'face-to-face') return null const [busy, setBusy] = useState('') const [note, setNote] = useState(null) const [tick, setTick] = useState(0) + const [snapshot, setSnapshot] = useState(null) + const candidate = (snapshot?.status || face?.status) === 'candidate' + useEffect(() => { + if (!candidate && face?.status !== 'candidate') return undefined + const load = () => call('social/face-rooms', { poll: true }).then(value => { + const list = Array.isArray(value?.rooms) ? value.rooms : [] + const next = list.find(item => (face?.roomId && item.faceRoomId === face.roomId) || item.roomId === room.id) + if (next) setSnapshot(next) + }, () => {}) + load() + if (typeof setInterval !== 'function') return undefined + const timer = setInterval(load, 2000) + timer?.unref?.() + return () => clearInterval(timer) + }, [candidate, face?.roomId, room?.id]) useEffect(() => { - if (face?.status !== 'candidate' || !face?.expiresAt) return undefined + const expiresAt = snapshot?.expiresAt || face?.expiresAt + if (!candidate || !expiresAt) return undefined + if (typeof setInterval !== 'function') return undefined const timer = setInterval(() => setTick(n => n + 1), 1000) timer?.unref?.() return () => clearInterval(timer) - }, [face?.status, face?.expiresAt]) + }, [candidate, snapshot?.expiresAt, face?.expiresAt]) const run = async (label, fn) => { setBusy(label) setNote(null) try { await fn() } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy('') } } - const candidate = face?.status === 'candidate' + const members = snapshot?.members + ?? (room.presentedParticipants ?? room.participants ?? []).filter(p => p.leftSeq === undefined) + const expiresAt = snapshot?.expiresAt || face?.expiresAt + if (candidate) { + return h('div', { className: 'fs-wait-overview', 'data-flybot': 'face-overview', 'data-wait': true }, + h('div', { className: 'fs-hint fs-wait-count', 'data-tick': tick, 'data-flybot': 'face-wait-count' }, + expiresAt ? countdown(expiresAt) : 'Anyone with the number can still join.'), + h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, + members.map(member => h(FaceTile, { + key: member.handle || member.id, + bot: faceLook({ roster, member }), + member, + handle: member.handle, + mine: member.remote === false || Boolean(member.id && roster?.byId?.[member.id]), + }))), + members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null, + h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-wait-foot' }, + h('button', { + type: 'button', className: 'fs-btn', 'data-flybot': 'face-leave', disabled: !!busy, + onClick: () => run('leave', () => call('social/face-leave', { roomId: room.id, faceRoomId: face?.roomId })), + }, 'Leave'), + h('button', { + type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', disabled: !!busy, + onClick: () => run('start', async () => { + await call('social/face-start', { roomId: room.id, faceRoomId: face?.roomId }) + if (room.id && typeof actions?.openSession === 'function') actions.openSession(room.id) + }), + }, 'Enter chat')), + note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) + } return h('div', { className: 'fs-face-row', 'data-flybot': 'face-overview' }, - candidate ? h('span', { className: 'fs-chip', 'data-tick': tick }, face?.expiresAt ? countdown(face.expiresAt) : 'joining') : h('span', { className: 'fs-chip' }, faceChip({ status: face?.status || 'open' })), + h('span', { className: 'fs-chip' }, faceChip({ status: snapshot?.status || face?.status || 'open' })), h(FaceAutoSendControl), h('span', { className: 'fs-face-actions' }, - candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.id, faceRoomId: face?.roomId })) }, 'Start') : null, h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.id, faceRoomId: face?.roomId })) }, 'Leave')), note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } @@ -959,7 +1054,6 @@ window.__ModuleLoader__.load({ pending.length ? h('button', { type: 'button', className: 'fs-tag fs-pending-tag', 'data-flybot': 'pending-badge', title: `${pending.length} outbound ${pending.length === 1 ? 'reply' : 'replies'} waiting for your approval. Opens that chat.`, onClick: openPending }, `${pending.length} to approve`) : null, refreshing ? h('span', { className: 'fs-refresh', 'data-flybot': 'roster-refreshing' }, 'Refreshing…') : null), open ? h('div', { className: 'fs-body' }, - h(PendingApprovals, { items: pending, onOpen: openItem }), unavailable ? null : h(FaceRooms, { actions, enabled: open, roster, remoteChats }), adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings), onDone: () => setAdding(false) }) : null, faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null, @@ -982,7 +1076,7 @@ window.__ModuleLoader__.load({ })))) }), data && homes.length === 0 && !adding - ? h('div', { className: 'fs-empty' }, 'No friends yet. Right-click one of your Bots and choose ', h('b', null, 'Copy invite link'), ' (or ', h('b', null, 'Copy invite with instructions'), '), or use the ', h('b', null, '+'), ' menu → ', h('b', null, 'Add friend'), ' to join a friend\'s invite.') + ? h('div', { className: 'fs-empty' }, 'No friends yet. Use ', h('b', null, 'Copy invite link'), ' in Add friend, or the ', h('b', null, '+'), ' menu → ', h('b', null, 'Add friend'), ' to join a friend’s invite.') : null) : null) } @@ -1018,6 +1112,23 @@ window.__ModuleLoader__.load({ setBusy(false) } } + const mint = () => run('Invite', async () => { + const botId = joiner || mine.find(bot => bot.isMain)?.id || mine[0]?.id + if (!botId) throw new Error('Choose one of your Bots to invite a friend to') + const invite = await call('social/create-invite', { botId }) + const link = invite?.link || invite?.url + if (!link) throw new Error('The invite came back without its link') + const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined + if (clip?.writeText) { + try { + await clip.writeText(link) + return 'Invite link copied. Send it to a friend.' + } catch { + return `Could not copy. Link: ${link}` + } + } + return `Invite link: ${link}` + }) const join = e => { e.preventDefault() return run('Joining', async () => { @@ -1037,7 +1148,9 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-face-head' }, h('div', { className: 'fs-label' }, 'Add friend'), onDone ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: onDone }, 'Done') : null), - h('div', { className: 'fs-hint', 'data-flybot': 'invite-hint' }, 'To invite a friend to one of your Bots, right-click it in the Bot list and choose ', h('b', null, 'Copy invite link'), ' for the bare link, or ', h('b', null, 'Copy invite with instructions'), ' for the link with how to join.'), + h('div', { className: 'fs-line', style: { padding: '4px 10px 0' } }, + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'mint-invite', disabled: busy, onClick: mint }, 'Copy invite link')), + h('div', { className: 'fs-hint', 'data-flybot': 'invite-hint' }, 'Or paste a friend’s invite below.'), h('form', { onSubmit: join, className: 'fs-card bt-social-join' }, h('div', { className: 'fs-hint' }, 'Got a link from a friend?'), h('div', { className: 'fs-line' }, diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index e23acc8fd..a7104c8d6 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -78,13 +78,20 @@ export function countdownLabel(expiresAt, now = Date.now()) { const at = Date.parse(expiresAt) if (!Number.isFinite(at)) return '' const left = Math.max(0, at - now) - if (left <= 0) return 'The matching window has ended' + if (left <= 0) return 'Joining has ended' const minutes = Math.ceil(left / 60_000) if (minutes >= 2) return `${minutes} minutes left to join` const seconds = Math.max(1, Math.ceil(left / 1000)) return seconds === 1 ? '1 second left to join' : `${seconds} seconds left to join` } +const ALREADY_LEFT = new Set(['bot_not_found', 'not_a_member', 'room_closed', 'face_room_not_found', 'code_void']) +export function alreadyLeftFace(error) { + const code = String(error?.code ?? '') + if (ALREADY_LEFT.has(code)) return true + return /not one of yours on Bot Social|no longer in this room|has closed|not available|no longer valid/i.test(String(error?.message ?? error ?? '')) +} + const FRIENDLY = { invalid_code: 'Enter a 6-digit number.', invalid_request: 'That request was not valid.', @@ -174,12 +181,28 @@ export function publishedForJoin(bot, isPublished) { return !roomOnlyForJoin(bot, typeof isPublished === 'function' ? id => isPublished(id) : undefined) } +export function handleBotName(handle) { + const part = String(handle ?? '').replace(/^@/, '').split('.')[0] + if (!part || part === 'home' || part === 'bot') return '' + return part.charAt(0).toUpperCase() + part.slice(1) +} + +export function distinctFaceBotName(member) { + const name = cleanFaceName(member?.display_name || member?.displayName || member?.name, '') + const owner = cleanFaceName(member?.owner_name || member?.ownerName, '') + if (/^chief$/i.test(name)) return 'Chief' + if (name && name !== 'Bot' && (!owner || name.toLowerCase() !== owner.toLowerCase())) return name + const fromHandle = handleBotName(member?.handle) + if (fromHandle && (!owner || fromHandle.toLowerCase() !== owner.toLowerCase())) return fromHandle + return name && name !== 'Bot' ? name : '' +} + export function faceMemberLabel(member) { - const name = cleanFaceName(member?.display_name || member?.displayName || member?.name, 'Bot') const owner = cleanFaceName(member?.owner_name || member?.ownerName, '') - if (!owner) return name + const name = distinctFaceBotName(member) || 'Bot' + if (!owner || name.toLowerCase() === owner.toLowerCase()) return name if (/^chief$/i.test(name)) return `${owner}'s Chief` - return `${name} · ${owner}'s Bot` + return `${name} · ${owner}` } export const FACE_HUMAN_MARK = 'FLYBOT-FACE-HUMAN' @@ -236,21 +259,18 @@ export function faceOutbound(line, { locals = [], asBots = [] } = {}) { } export function platformJoinText(member) { - const name = cleanFaceName(member?.display_name || member?.displayName, 'Bot') - const owner = cleanFaceName(member?.owner_name || member?.ownerName, 'Someone') + const who = faceMemberLabel(member) // Server copy; we rebuild from sanitized fields so a poisoned payload cannot instruct. - if (member?.roomOnly === true || member?.published !== true) return `${owner}'s ${name} joined; available to this room only` - return `${owner}'s ${name} joined` + if (member?.roomOnly === true || member?.published !== true) return `${who} joined; available to this room only` + return `${who} joined` } export function platformLeaveText(member) { - const name = cleanFaceName(member?.display_name || member?.displayName, 'Bot') - const owner = cleanFaceName(member?.owner_name || member?.ownerName, 'Someone') - return `${owner}'s ${name} left` + return `${faceMemberLabel(member)} left` } export function platformStartedText(reason) { - if (reason === 'expired') return 'The matching window ended. The room is now open. The number is no longer valid.' + if (reason === 'expired') return 'Joining has ended. The room is now open. The number is no longer valid.' return 'The room is now open. The number is no longer valid.' } diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index cc3e786f8..c580ef916 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -39,6 +39,7 @@ import { renderFaceEvent, rememberEvent, FaceRoomError, + alreadyLeftFace, roomOnlyForJoin, faceMemberLabel, viewMe, @@ -71,6 +72,7 @@ export const cleanText = (value, fallback = '') => String(value ?? '').replace(/ // Owner-only draft body: keep punctuation the friend will see; strip controls and cap. export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800) // Bot Social list_approvals card → the Friends' Bots queue (outbound drafts only). +const looksLikeBotId = (value) => /^bot_[a-z0-9]+$/i.test(String(value ?? '').trim()) export function approvalView(item, { localName } = {}) { if (!item || typeof item !== 'object') return undefined const id = String(item.approval_id ?? item.id ?? '') @@ -79,11 +81,17 @@ export function approvalView(item, { localName } = {}) { const payload = item.message?.payload const text = draftText(payload && typeof payload === 'object' ? payload.text : payload) || draftText(item.summary) const kind = String(item.kind ?? '') + const named = typeof localName === 'function' ? localName(item.bot_id) : '' + const fromRaw = named + || (typeof item.from === 'string' ? item.from : item.from?.display_name) + || item.sender_name + || item.bot?.display_name + const from = cleanText(fromRaw) || (looksLikeBotId(item.bot_id) ? 'Bot' : cleanText(item.bot_id)) return { id, kind, status: String(item.status ?? 'pending'), - from: typeof localName === 'function' ? (localName(item.bot_id) || cleanText(item.bot_id)) : cleanText(item.bot_id), + from, to: to?.handle ? norm(to.handle) : undefined, toName: cleanText(to?.display_name) || (kind === 'face_room_message' ? 'the room' : undefined), roomId: item.room_id || item.face_room_id || item.message?.room_id, @@ -1533,7 +1541,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp rememberPastFace(viewHostedFace(hosted, { faceRoomId: id, localRoomId: meta?.localRoomId, status: 'left' }) || { faceRoomId: id, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'left', members: [] }) const handles = (Array.isArray(bots) && bots.length ? bots : asBotsOf(meta)).map(norm).filter(Boolean) - const result = await faceNeed().leave(id, { bots: handles }) + let result + try { + result = await faceNeed().leave(id, { bots: handles }) + } catch (error) { + // Matching already ended, or Bot Social dropped the room-only identity: still + // move the room to Past rooms instead of leaving it in the active list. + if (!alreadyLeftFace(error)) throw error + result = { status: 'left' } + } if (meta?.localRoomId) { for (const handle of asBotsOf(meta)) { const leftBot = botByHandle(handle) diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 9d74d6c12..9a2f03980 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -19,6 +19,8 @@ import { platformLeaveText, platformStartedText, faceMemberLabel, + distinctFaceBotName, + alreadyLeftFace, roomOnlyForJoin, publishedForJoin, createFaceRoomApi, @@ -77,7 +79,7 @@ test('error mapping covers the FACE_ROOMS.md codes', () => { test('countdown copy for a candidate room', () => { const now = Date.parse('2026-10-15T10:00:00.000Z') assert.match(countdownLabel('2026-10-15T10:08:00.000Z', now), /8 minutes left/) - assert.equal(countdownLabel('2026-10-15T09:00:00.000Z', now), 'The matching window has ended') + assert.equal(countdownLabel('2026-10-15T09:00:00.000Z', now), 'Joining has ended') }) test('peer names and system copy are sanitized', () => { @@ -91,11 +93,16 @@ test('peer names and system copy are sanitized', () => { assert.doesNotMatch(text, / { @@ -132,7 +139,7 @@ test('peer room.message text is fenced and cannot rebuild a closing marker', () }) const line = renderFaceEvent(event) assert.equal(line.trust, 'peer_untrusted') - assert.match(line.fenced, //) + assert.match(line.fenced, //) assert.equal(line.fenced.match(/<\/untrusted-message>/g).length, 1) assert.ok(line.fenced.endsWith('')) assert.doesNotMatch(stripFaceMarkers(line.text), /<\/\s*untrusted-message/i) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 24a33a4e4..09289f612 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -968,6 +968,15 @@ test('approvalView: outbound draft text is sanitized, friend requests stay out', assert.equal(approvalView({ approval_id: 'x', kind: 'invite', status: 'pending' }).kind, 'invite') assert.equal(approvalView(undefined), undefined) assert.equal(draftText('a\n\nb'), 'a b') + const raw = approvalView({ + approval_id: 'apr-raw', + kind: 'face_room_message', + bot_id: 'bot_01abc', + message: { payload: { text: 'hold this' } }, + }) + assert.equal(raw.from, 'Bot') + assert.equal(raw.toName, 'the room') + assert.doesNotMatch(raw.from, /bot_01/) }) test('Friends\' Bots (#69): a pending approval shows a badge and a one-line jump row', () => { @@ -1003,8 +1012,7 @@ test('Friends\' Bots (#69): a pending approval shows a badge and a one-line jump } walk(tree) assert.ok(all.some(n => n.props['data-flybot'] === 'pending-badge' && n.children.includes('1 to approve'))) - assert.ok(all.some(n => n.props['data-flybot'] === 'pending-approvals')) - assert.ok(all.some(n => n.props.className === 'fs-pending-one' && n.children.includes('I can review that'))) + assert.equal(all.some(n => n.props['data-flybot'] === 'pending-approvals'), false) assert.equal(all.some(n => n.type === 'button' && n.children.includes('Approve')), false) const opened = [] const card = plugin.PendingApprovals({ items: pending, onOpen: item => opened.push(item.id) }) @@ -1199,6 +1207,30 @@ test('face-to-face join hosts a native room, maps errors, starts and leaves', as assert.equal(ctx.flybot.rooms.ofFace('frm_123456'), undefined) }) +test('face-to-face leave after matching ends still moves the room to Past rooms', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + 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', { code: '123456' }) + // Matching already ended: Bot Social no longer has the room (same class as bot_not_found). + server.face.rooms.delete('frm_123456') + const left = await call('flybot/social/face-leave', { faceRoomId: 'frm_123456' }) + assert.equal(left.ok, true) + assert.equal(left.value.status, 'left') + assert.equal(ctx.flybot.rooms.ofFace('frm_123456'), undefined) + const listed = (await call('flybot/social/face-rooms', { poll: false })).value + const past = listed.rooms.filter(room => room.faceRoomId === 'frm_123456') + assert.equal(past.length, 1) + assert.equal(past[0].status, 'left') +}) + test('face-to-face: a member already in the snapshot gets a join line when the event is missing', async (t) => { const { ctx, call, stop } = await team() t.after(stop) @@ -1216,7 +1248,7 @@ test('face-to-face: a member already in the snapshot gets a join line when the e }) await call('flybot/social/face-rooms', { poll: true }) const hosted = ctx.flybot.rooms.ofFace('frm_123456') - assert.ok((hosted.log ?? []).some(line => /Bob's Coder joined/.test(line.text))) + assert.ok((hosted.log ?? []).some(line => /Coder · Bob joined/.test(line.text))) const live = (hosted.participants ?? []).filter(p => p.leftSeq === undefined && p.handle === '@coder.bob.home') assert.equal(live.length, 1) }) @@ -1585,6 +1617,9 @@ test('Friends\' Bots (#83): auto-send toggle in the room header and pending-appr assert.ok(header.some(n => n.type === 'label' && n.children.includes('Auto-send'))) assert.ok(header.some(n => n.props.title && /without asking each time/.test(n.props.title))) assert.equal(header.some(n => n.type === 'button' && n.children.includes('Turn on auto-send')), false) + assert.ok(header.some(n => n.type === 'button' && n.children.includes('Leave'))) + assert.equal(header.some(n => n.type === 'button' && n.children.includes('Start')), false) + assert.equal(header.some(n => n.type === 'button' && n.children.includes('Open')), false) const box = header.find(n => n.type === 'input' && n.props.type === 'checkbox') assert.equal(box.props.checked, false) await box.props.onChange({ target: { checked: true } }) @@ -1660,8 +1695,9 @@ test('Face-to-face room card is one compact row with a status chip and no invali assert.ok(tree.some(n => n.props.className === 'fs-face-row')) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('open'))) assert.ok(tree.some(n => n.props.className === 'fs-stack-more' && n.children.includes('+1'))) - assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Open')), false) assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Leave'))) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Start')), false) assert.equal(tree.some(n => typeof n.children?.[0] === 'string' && /no longer valid/.test(n.children[0])), false) assert.equal(tree.some(n => n.props['data-flybot'] === 'face-auto-send'), false) assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.fs-face-row\{[^}]*align-items:center/) @@ -1686,6 +1722,10 @@ test('Face-to-face past rooms sit under a Past rooms fold; Auto-send is once in const opened = walk(pluginOpen.FaceRooms({})) assert.ok(opened.some(n => n.props['data-status'] === 'closed')) assert.ok(opened.some(n => n.props['data-status'] === 'left')) + assert.equal(opened.filter(n => n.props['data-flybot'] === 'face-room' && n.props['data-past']).length, 2) + assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Open')).length, 2) + assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 1) + assert.equal(opened.some(n => n.type === 'button' && n.children.includes('Start')), false) const pastOnly = walk(pluginOf([ [{ faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }], @@ -1711,8 +1751,82 @@ test('Face-to-face room card uses a short status chip so Start is not clipped', assert.ok(chip) assert.match(String(chip.children.join('')), /^\d+m$/) assert.equal(tree.some(n => typeof n.children?.[0] === 'string' && /minutes left/.test(n.children[0])), false) - assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Start'))) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Start')), false) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Open')), false) + assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Leave'))) +}) + +test('Face-to-face waiting overview is a tile grid with Leave and Enter chat', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([]) + const tree = walk(plugin.FaceRoomOverview({ + room: { + id: 'g1', origin: 'face-to-face', + face: { roomId: 'frm_1', status: 'candidate', expiresAt: '2099-01-01T00:00:00.000Z' }, + participants: [ + { handle: '@a.me.home', displayName: 'Chief', ownerName: 'Me', remote: false, id: 'a' }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', remote: true }, + ], + }, + roster: { byId: { a: { id: 'a', name: 'Chief' } } }, + })) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-overview' && n.props['data-wait'])) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-wait-roster' && /\bfs-tiles\b/.test(n.props.className))) + assert.ok(tree.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-enter' && n.children.includes('Enter chat'))) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-leave' && n.children.includes('Leave'))) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Start')), false) + assert.equal(tree.some(n => n.props['data-flybot'] === 'face-auto-send'), false) +}) + +test('Face-to-face past rooms hide duplicate ids and keep Open as the only action', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const rooms = [ + { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }, + { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'left', members: [] }, + ] + const tree = walk(pluginOf([rooms, false, false, '', true]).FaceRooms({})) + assert.equal(tree.filter(n => n.props['data-flybot'] === 'face-room').length, 1) assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) + assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Leave')), false) +}) + +test('Add friend mints and copies an invite link in one click', async () => { + const copied = [] + const calls = [] + const { walk, pluginOf, sandbox } = loadSocialClient(async (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'social/create-invite') return { link: 'https://s/i/INV123', url: 'https://s/i/INV123' } + if (endpoint === 'social/overview') return { incoming: [], outgoing: [] } + if (endpoint === 'social/status') return { bots: [{ id: 'a', name: 'Chief', isMain: true }] } + return {} + }) + sandbox.navigator = { clipboard: { writeText: async (text) => { copied.push(text) } } } + const mine = [{ id: 'a', name: 'Chief', isMain: true }] + const tree = walk(pluginOf([null, '', null, false, mine, 'a']).AddFriend({})) + const mint = tree.find(n => n.props['data-flybot'] === 'mint-invite') + assert.ok(mint) + assert.ok(mint.children.includes('Copy invite link')) + assert.match(tree.find(n => n.props['data-flybot'] === 'invite-hint').children.join(''), /paste a friend.s invite/i) + await mint.props.onClick() + assert.ok(calls.some(([endpoint, payload]) => endpoint === 'social/create-invite' && payload.botId === 'a')) + assert.deepEqual(copied, ['https://s/i/INV123']) +}) + +test('#126: a face-room hold shows in that chat with a readable name', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([ + [{ id: 'apr-face', kind: 'face_room_message', from: 'Chief', toName: 'the room', text: 'hello there' }], + [], + '', + ]) + const tree = walk(plugin.ChatApprovalDock({ + room: { origin: 'face-to-face', id: 'g1', face: { roomId: 'frm_1' } }, + })) + assert.equal(tree.filter(n => n.props['data-flybot'] === 'chat-approval').length, 1) + assert.ok(tree.some(n => n.props.className === 'fs-sub' && n.children.includes('Chief → the room'))) + assert.ok(tree.some(n => n.props.className === 'fs-draft' && n.children.includes('hello there'))) }) test('Face-to-face rooms list polls Bot Social while Friends\' Bots is open', () => { @@ -1753,6 +1867,18 @@ test('#114: in-chat dock lists drafts with Approve all; others see a waiting lin const waiting = walk(plugin.ChatApprovalDock({ room: { pendingCount: 2 } })) assert.ok(waiting.some(n => n.props['data-role'] === 'other')) assert.ok(waiting.some(n => n.children?.includes?.(WAITING_OTHERS) || (Array.isArray(n.children) && n.children.includes(WAITING_OTHERS)))) + + const named = walk(plugin.ChatApproval({ + item: { id: 'apr-raw', kind: 'face_room_message', from: 'bot_01abc', text: 'hold this' }, + canDecide: true, + })) + assert.ok(named.some(n => n.props.className === 'fs-sub' && n.children.includes('Bot → the room'))) + assert.equal(named.some(n => String(n.children ?? '').includes('bot_01')), false) + + const roomHold = walk(plugin.ChatApprovalDock({ + room: { origin: 'face-to-face', face: { roomId: 'frm_1' } }, + })) + assert.equal(roomHold.filter(n => n.props['data-flybot'] === 'chat-approval').length, 0) }) test('Bot settings: Manage access lists grants and revokes one', async () => { @@ -1930,11 +2056,12 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.equal(peerMark.props['data-image'], 'https://example.test/coder.png') assert.equal(peerMark.props['data-size'], 48) assert.notEqual(mineMark.props['data-bot-id'], peerMark.props['data-bot-id']) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief · Me'))) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder · Bob'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder'))) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Bob'))) assert.equal(mine.children.some(n => n.props?.className === 'fs-sub' || (typeof n.props?.className === 'string' && n.props.className.includes('fs-tag'))), false) assert.equal(peer.children.some(n => n.props?.className === 'fs-sub'), false) - assert.equal(wait.some(n => n.props['data-flybot'] === 'face-owner'), false) assert.equal(wait.some(n => n.props.className === 'fs-wait-hint' || n.props.className === 'fs-wait-name'), false) assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-count')) const foot = wait.find(n => n.props['data-flybot'] === 'face-wait-foot') @@ -1990,9 +2117,8 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch cursor = 0 const liveNodes = liveClient.walk(livePlugin.FaceToFaceGroup({ actions: {} })) assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@writer.alice.home')) - assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer · Alice'))) - assert.equal(liveNodes.some(n => n.props['data-flybot'] === 'face-owner'), false) - assert.equal(liveNodes.some(n => Array.isArray(n.children) && n.children.includes('Alice')), false) + assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer'))) + assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Alice'))) const autoOpened = [] const autoPlugin = pluginOf(['waiting', bots, ['a'], '123456', false, null, [], { ...room, status: 'open' }, 0], true)