diff --git a/changelog.d/158-face-wait-code.fixed.md b/changelog.d/158-face-wait-code.fixed.md new file mode 100644 index 000000000..974c995f1 --- /dev/null +++ b/changelog.d/158-face-wait-code.fixed.md @@ -0,0 +1 @@ +The face-to-face waiting room shows the 6-digit room number clearly. Setup copy no longer says there is no preview: you wait together until someone enters the chat. ([#158](https://github.com/ThinkFlowLab/FlyBot/issues/158)) diff --git a/changelog.d/159-face-human-approval.fixed.md b/changelog.d/159-face-human-approval.fixed.md new file mode 100644 index 000000000..1dc36799f --- /dev/null +++ b/changelog.d/159-face-human-approval.fixed.md @@ -0,0 +1 @@ +A face-to-face owner line held for approval shows the human text from the trusted sender, never a flash of the raw `FLYBOT-FACE-HUMAN` envelope. The card stays until Approve, Always approve, or Reject unless face Auto-send is on. ([#159](https://github.com/ThinkFlowLab/FlyBot/issues/159)) diff --git a/changelog.d/160-remote-bot-settings.changed.md b/changelog.d/160-remote-bot-settings.changed.md new file mode 100644 index 000000000..2f8088f53 --- /dev/null +++ b/changelog.d/160-remote-bot-settings.changed.md @@ -0,0 +1 @@ +A friend's Bot settings page is a remote-friend surface, not the local editor: Auto-send and Remove this Bot stay there, without rename, model, Hide, Duplicate, or Delete. Remove closes the overlay. ([#160](https://github.com/ThinkFlowLab/FlyBot/issues/160)) diff --git a/changelog.d/161-invite-joiner-chief.fixed.md b/changelog.d/161-invite-joiner-chief.fixed.md new file mode 100644 index 000000000..44a330414 --- /dev/null +++ b/changelog.d/161-invite-joiner-chief.fixed.md @@ -0,0 +1 @@ +Accepting a friend invite no longer lists the joiner's talk-through Bot (Chief) on the inviter's Friends list. Reverse-friends of invites this home created stay out of `list_remote_bots` even on a fresh install, before inbound backfill. ([#161](https://github.com/ThinkFlowLab/FlyBot/issues/161)) diff --git a/changelog.d/162-talk-through-wrap.fixed.md b/changelog.d/162-talk-through-wrap.fixed.md new file mode 100644 index 000000000..a7b208189 --- /dev/null +++ b/changelog.d/162-talk-through-wrap.fixed.md @@ -0,0 +1 @@ +In Add friend, **Talk through** stays on one line instead of wrapping mid-word. ([#162](https://github.com/ThinkFlowLab/FlyBot/issues/162)) diff --git a/core/client.js b/core/client.js index f37a19668..edea47fac 100644 --- a/core/client.js +++ b/core/client.js @@ -8901,6 +8901,25 @@ function BotSettingsForm({ bot, roster, actions, run }) { const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); }, [confirming]); + if (bot.remote) { + const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot"); + return (0, import_react19.createElement)( + "div", + { className: "bt-bs", "data-flybot": "remote-bot-settings" }, + (0, import_react19.createElement)( + "div", + { className: "bt-bs-hero" }, + (0, import_react19.createElement)(BotMark, { bot, size: 64, live: true }), + (0, import_react19.createElement)( + "div", + { className: "bt-bs-who" }, + (0, import_react19.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react19.createElement)("span", { className: "bt-bs-sub" }, who) + ) + ), + (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }) + ); + } const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; @@ -11705,8 +11724,8 @@ function BotsPage({ roster, actions, run }) { ), (0, import_react29.createElement)( SetSection, - { key: "bots", title: t("Bots · {count}", { count: roster.bots.length }) }, - roster.bots.map((bot) => (0, import_react29.createElement)( + { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) }, + roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react29.createElement)( "button", { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) }, (0, import_react29.createElement)( @@ -11873,7 +11892,16 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; - const groups = [ + const groups = bot?.remote ? [ + [ + [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] + ], + [ + [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)] + ], + pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), + [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] + ] : [ [ isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] @@ -11896,11 +11924,12 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true] ] - ].map((group) => group.filter(Boolean)).filter((group) => group.length > 0); + ]; + const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); return (0, import_react30.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 }, - groups.map((group, index) => (0, import_react30.createElement)( + shown.map((group, index) => (0, import_react30.createElement)( import_react30.Fragment, { key: index }, index > 0 ? (0, import_react30.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, @@ -12559,7 +12588,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a "div", { key: "head", className: "bt-subhead", "data-move": move }, (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react32.createElement)(ChevronLeftIcon)), - (0, import_react32.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), + (0, import_react32.createElement)("span", { className: "bt-subhead-title" }, bot ? bot.remote ? t("Bot settings") : t("Settings") : t("Group settings")), hasStrip ? null : (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react32.createElement)(CloseIcon)) ), (0, import_react32.createElement)( @@ -12573,8 +12602,8 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const top = (0, import_react32.createElement)( "div", { className: "bt-drawer-top", "data-move": side }, - bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? t("Edit Bot") : t("Group settings"), title: bot ? t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => setView("settings") }, (0, import_react32.createElement)(GearIcon)) : null, - bot && roster.browser === true && actions.openBrowser ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react32.createElement)(GlobeIcon)) : null, + bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => setView("settings") }, (0, import_react32.createElement)(GearIcon)) : null, + bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react32.createElement)(GlobeIcon)) : null, bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react32.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react32.createElement)(ShareIcon)) : null, hasStrip ? null : (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react32.createElement)(CloseIcon)) ); @@ -12600,7 +12629,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a "div", { key: "id", className: "bt-drawer-id", "data-move": side }, bot ? (0, import_react32.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - editing ? (0, import_react32.createElement)("input", { + bot?.remote ? (0, import_react32.createElement)("div", { className: "bt-drawer-name" }, current) : editing ? (0, import_react32.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, @@ -12619,9 +12648,9 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setDraftName(room2 && !room2.named ? "" : current); setEditing(true); } }, current), - subtitle ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, subtitle) : null + subtitle ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ), - (0, import_react32.createElement)( + bot?.remote ? null : (0, import_react32.createElement)( "div", { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side }, (0, import_react32.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), @@ -12632,7 +12661,10 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move }, errorLine, room2 && tab === "details" ? (0, import_react32.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, - bot && tab === "details" ? [ + bot && bot.remote && tab === "details" ? [ + bot.remote.handle ? (0, import_react32.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null + ] : null, + bot && !bot.remote && tab === "details" ? [ (0, import_react32.createElement)( "div", { key: "model", className: "bt-drawer-model" }, @@ -12657,7 +12689,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a ) : null, (0, import_react32.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ] : null, - bot && tab === "browser" ? (0, import_react32.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null + bot && !bot.remote && tab === "browser" ? (0, import_react32.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 2225f9fed..802fdc367 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -200,6 +200,16 @@ export function BotSettingsForm({ bot, roster, actions, run }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) + if (bot.remote) { + const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot") + return h('div', { className: 'bt-bs', 'data-flybot': 'remote-bot-settings' }, + h('div', { className: 'bt-bs-hero' }, + h(BotMark, { bot, size: 64, live: true }), + h('div', { className: 'bt-bs-who' }, + h('span', { className: 'bt-bs-name' }, bot.name), + h('span', { className: 'bt-bs-sub' }, who))), + h(FlyBotDetails, { bot, roster, actions })) + } const isMainBot = isMainOf(roster, bot.id) const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 const isTile = roster.mainBotId === bot.id diff --git a/core/src/client/context-menu.js b/core/src/client/context-menu.js index 2fc397c61..9f1c5d6b8 100644 --- a/core/src/client/context-menu.js +++ b/core/src/client/context-menu.js @@ -31,29 +31,41 @@ export function ContextMenu({ menu, roster, actions, leaving = false }) { const isMainBot = isMainOf(roster, menu.id) const isTile = roster.mainBotId === menu.id const mainCount = (roster.mainBotIds ?? []).length - const groups = [ - [ - isTile ? null : [entry.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], - [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], - ], - [ - bot ? [t('Rename Bot'), PencilIcon, () => actions.renameBot(bot.id)] : null, - bot ? [t('Edit Bot'), GearIcon, () => actions.openSettings('bots', bot.id)] : null, - room ? [t('Group settings'), GearIcon, () => actions.openSettings('groups', room.id)] : null, - room ? [t('Rename group'), PencilIcon, () => actions.renameBot(room.id)] : null, - bot ? [t('Duplicate'), DuplicateIcon, async () => { const copy = await actions.duplicateBot(bot.id); actions.openSession(copy.id) }] : null, - bot && !isMainBot ? [t('Make Main Bot'), StarLineIcon, () => actions.setMain(bot.id, true)] : null, - bot && isMainBot && mainCount > 1 ? [t('Remove Main Bot role'), StarLineIcon, () => actions.setMain(bot.id, false)] : null, - ], - bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [], - [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]], - [ - isMainBot ? null : [t('Hide from sidebar'), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], - isMainBot ? null : [room ? t('Delete group') : t('Delete'), TrashIcon, () => (bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id)), true], - ], - ].map(group => group.filter(Boolean)).filter(group => group.length > 0) + const groups = bot?.remote + ? [ + [ + [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], + ], + [ + [t('Bot settings'), GearIcon, () => actions.openSettings('bots', bot.id)], + ], + pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), + [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]], + ] + : [ + [ + isTile ? null : [entry.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], + [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], + ], + [ + bot ? [t('Rename Bot'), PencilIcon, () => actions.renameBot(bot.id)] : null, + bot ? [t('Edit Bot'), GearIcon, () => actions.openSettings('bots', bot.id)] : null, + room ? [t('Group settings'), GearIcon, () => actions.openSettings('groups', room.id)] : null, + room ? [t('Rename group'), PencilIcon, () => actions.renameBot(room.id)] : null, + bot ? [t('Duplicate'), DuplicateIcon, async () => { const copy = await actions.duplicateBot(bot.id); actions.openSession(copy.id) }] : null, + bot && !isMainBot ? [t('Make Main Bot'), StarLineIcon, () => actions.setMain(bot.id, true)] : null, + bot && isMainBot && mainCount > 1 ? [t('Remove Main Bot role'), StarLineIcon, () => actions.setMain(bot.id, false)] : null, + ], + bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [], + [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]], + [ + isMainBot ? null : [t('Hide from sidebar'), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], + isMainBot ? null : [room ? t('Delete group') : t('Delete'), TrashIcon, () => (bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id)), true], + ], + ] + const shown = groups.map(group => group.filter(Boolean)).filter(group => group.length > 0) return h('div', { ref, className: 'bt-menu', role: 'menu', 'aria-label': t('{name} actions', { name: bot?.name ?? room.name }), style: place, 'data-leaving': leaving || undefined }, - groups.map((group, index) => h(Fragment, { key: index }, + shown.map((group, index) => h(Fragment, { key: index }, index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, group.map(([label, Icon, run, danger]) => h('button', { key: label, type: 'button', role: 'menuitem', className: danger ? 'bt-danger' : undefined, diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index c89fdebdc..ae9cf891d 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -251,15 +251,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro return [ h('div', { key: 'head', className: 'bt-subhead', 'data-move': move }, h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Back to details'), onClick: () => setView('overview') }, h(ChevronLeftIcon)), - h('span', { className: 'bt-subhead-title' }, bot ? t('Settings') : t('Group settings')), + h('span', { className: 'bt-subhead-title' }, bot ? (bot.remote ? t('Bot settings') : t('Settings')) : t('Group settings')), hasStrip ? null : h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))), h('div', { key: 'set-body', className: 'bt-drawer-body bt-set-body', 'data-move': move }, errorLine, bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ] } const top = h('div', { className: 'bt-drawer-top', 'data-move': side }, - bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': bot ? t('Edit Bot') : t('Group settings'), title: bot ? t('Edit Bot') : t('Group settings'), 'data-info-row': 'settings', onClick: () => setView('settings') }, h(GearIcon)) : null, - bot && roster.browser === true && actions.openBrowser + bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': bot ? (bot.remote ? t('Bot settings') : t('Edit Bot')) : t('Group settings'), title: bot ? (bot.remote ? t('Bot settings') : t('Edit Bot')) : t('Group settings'), 'data-info-row': 'settings', onClick: () => setView('settings') }, h(GearIcon)) : null, + bot && !bot.remote && roster.browser === true && actions.openBrowser ? h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Open browser'), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, h(GlobeIcon)) : null, bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, @@ -284,22 +284,27 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro h('div', { key: 'id', className: 'bt-drawer-id', 'data-move': side }, bot ? h(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== undefined), main: isMainBot, badge: false, gaze: true, pokeable: true }) : h(RoomAvatar, { room, roster, size: 64 }), - editing - ? h('input', { - className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), - placeholder: room ? t('Name after members') : undefined, - onChange: event => setDraftName(event.target.value), onBlur: saveName, - onKeyDown: (event) => { if (event.key === 'Enter') void saveName(); if (event.key === 'Escape') { event.stopPropagation(); setEditing(false) } }, - }) - : h('button', { type: 'button', className: 'bt-drawer-name', 'aria-label': t('Rename {name}', { name: current }), title: t('Rename'), onClick: () => { setDraftName(room && !room.named ? '' : current); setEditing(true) } }, current), - subtitle ? h('span', { className: 'bt-drawer-role' }, subtitle) : null), - h('div', { key: 'tabs', ref: glide.box, className: 'bt-tabs', role: 'tablist', 'data-move': side }, + bot?.remote + ? h('div', { className: 'bt-drawer-name' }, current) + : editing + ? h('input', { + className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), + placeholder: room ? t('Name after members') : undefined, + onChange: event => setDraftName(event.target.value), onBlur: saveName, + onKeyDown: (event) => { if (event.key === 'Enter') void saveName(); if (event.key === 'Escape') { event.stopPropagation(); setEditing(false) } }, + }) + : h('button', { type: 'button', className: 'bt-drawer-name', 'aria-label': t('Rename {name}', { name: current }), title: t('Rename'), onClick: () => { setDraftName(room && !room.named ? '' : current); setEditing(true) } }, current), + subtitle ? h('span', { className: 'bt-drawer-role' }, subtitle) : bot?.remote ? h('span', { className: 'bt-drawer-role' }, t("Friend's Bot")) : null), + bot?.remote ? null : h('div', { key: 'tabs', ref: glide.box, className: 'bt-tabs', role: 'tablist', 'data-move': side }, h('span', { ref: glide.thumb, className: 'bt-thumb', 'aria-hidden': true }), tabs.map(key => h('button', { key, type: 'button', role: 'tab', className: 'bt-tab', 'aria-selected': tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))), h('div', { key: `body:${tab}`, className: 'bt-drawer-body', 'data-move': move }, errorLine, room && tab === 'details' ? h(GroupOverview, { room, roster, actions, usage, useWork, openSettings: () => setView('settings') }) : null, - bot && tab === 'details' ? [ + bot && bot.remote && tab === 'details' ? [ + bot.remote.handle ? h('div', { key: 'handle', className: 'bt-muted' }, bot.remote.handle) : null, + ] : null, + bot && !bot.remote && tab === 'details' ? [ h('div', { key: 'model', className: 'bt-drawer-model' }, h('div', { className: 'bt-section-title' }, t('Model')), h(ModelMenu, { bot, roster, actions, run, wide: true }), @@ -316,5 +321,5 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro h('div', { className: 'bt-kv' }, h('span', null, bot.name), h('span', null, usage))) : null, h(ScheduleSummary, { key: 'routines', bot, actions }), ] : null, - bot && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null)] + bot && !bot.remote && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null)] } diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 261161ce0..2e35f94f9 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -112,8 +112,8 @@ function BotsPage({ roster, actions, run }) { ? h(CreateBot, { key: 'create', roster, actions, inline: true, onBack: () => setCreating(false), onCreated: bot => actions.openSettings('bots', bot.id) }) : h('div', { key: 'new', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => setCreating(true) }, h(PlusIcon), t('New Bot'))), - h(SetSection, { key: 'bots', title: t('Bots · {count}', { count: roster.bots.length }) }, - roster.bots.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row', onClick: () => actions.openSettings('bots', bot.id) }, + h(SetSection, { key: 'bots', title: t('Bots · {count}', { count: roster.bots.filter(bot => !bot.remote).length }) }, + roster.bots.filter(bot => !bot.remote).map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row', onClick: () => actions.openSettings('bots', bot.id) }, h('span', { className: 'bt-set-bot' }, h(BotAvatar, { bot, size: 28, badge: false, live: false }), h('span', { className: 'bt-row-title' }, bot.name), diff --git a/core/test/looks.test.js b/core/test/looks.test.js index 6cbd0263e..7a5056d06 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -40,7 +40,12 @@ test('Bot settings is the only settings entry; nothing is called Team settings', assert.match(text('palette.js'), /title: t\('Bot settings'\), run: \(\) => actions\.openSettings\('general'\)/) // One Bot's own entries open that Bot, so they read Edit Bot instead. assert.match(text('context-menu.js'), /\[t\('Edit Bot'\), GearIcon, \(\) => actions\.openSettings\('bots', bot\.id\)\]/) - assert.match(text('details-drawer.js'), /'aria-label': bot \? t\('Edit Bot'\) : t\('Group settings'\), title: bot \? t\('Edit Bot'\) : t\('Group settings'\)/) + assert.match(text('context-menu.js'), /bot\?\.remote/) + assert.match(text('context-menu.js'), /\[t\('Bot settings'\), GearIcon, \(\) => actions\.openSettings\('bots', bot\.id\)\]/) + assert.match(text('details-drawer.js'), /bot\.remote \? t\('Bot settings'\) : t\('Edit Bot'\)/) + assert.match(text('bot-settings.js'), /if \(bot\.remote\)/) + assert.match(text('bot-settings.js'), /Friend's Bot/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) // A group chat's entries open its own settings the same way. assert.match(text('context-menu.js'), /\[t\('Group settings'\), GearIcon, \(\) => actions\.openSettings\('groups', room\.id\)\]/) assert.ok(!text('mark.js').includes('WhaleLogo')) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index ffb9ac089..76f701997 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -60,6 +60,7 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send - A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). - A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). +- A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. ### FlyBot product chrome diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index e38efc7c9..17ae8efd9 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -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. 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 displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. 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). A held owner line stays on the in-chat approval card (human text, never the raw envelope) unless face Auto-send is on. Bot replies stay bot-authored. The displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. Still not team mode. ### Host Bot look for classic-script plugins diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index c6b0cb276..866af3382 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, **Accept invite**, **Copy invite link**, and **Copy my handle** are those rows; the social strip has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, **Accept invite**, **Copy invite link**, and **Copy my handle** are those rows; the social strip has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -32,7 +32,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend / Accept invite / Copy invite link / Copy my handle; in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid then Leave / Auto-send; Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend / Accept invite / Copy invite link / Copy my handle; in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -45,7 +45,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Invite to this Bot** is the grant the other way; joining does not grant my talk-through Bot back. Join always offers **Talk through** (default Chief). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Sidebar **+** → **Copy my handle** copies the connected home handle (the toast repeats it). -6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+**); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. +6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. ### Inline approvals and compact Friends' Bots (#114, #115) @@ -54,9 +54,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### 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”). +- Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: the 6-digit room number at the top, 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`. Setup copy says you wait together until someone enters the chat. 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 the main-list RoomRow (people mark) with **Leave** on the open room overview. -- Sidebar **+** is a short action menu, not the To:/composer. Add friend has one-click **Copy invite link** (mint + clipboard). **Copy my handle** copies `@your.home` in the same click (toast repeats the handle). The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…`. +- Sidebar **+** is a short action menu, not the To:/composer. Add friend has one-click **Copy invite link** (mint + clipboard). **Talk through** stays on one line (nowrap label). **Copy my handle** copies `@your.home` in the same click (toast repeats the handle). The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. ### Face-room poll identity (#122) @@ -70,7 +70,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Sidebar **+** → **Copy my handle** copies `@your.home` (toast repeats it). - **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. -- **#138:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. Core grants store a canonical `@handle` (never decorated `Chief@…`). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. +- **#138 / #161:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles` wins over `inboundBots`) and room-only remotes. On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. @@ -83,7 +83,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Face-to-face two-instance rooms (#117) -- The social strip polls `social/face-rooms` with `poll: true` about every 5s, so a newly ended room appears under **Past rooms** without a reload. Live rooms are hosted in the core sidebar. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends. The host still publishes listed remotes into the main sidebar. +- The social strip polls `social/face-rooms` with `poll: true` about every 5s, so a newly ended room appears under **Past rooms** without a reload. Live rooms are hosted in the core sidebar. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot. The owner's click/type is consent to send; a `pending_approval` stays in the in-chat card until Approve / Always approve / Reject unless face Auto-send is on (#159). The other instance applies the line as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Owner cards unwrap the envelope to the human text. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends. The host still publishes listed remotes into the main sidebar. ### Inbound shared groups diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 28f8825de..ce2848cd7 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -75,6 +75,23 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js import { createElement as h, Fragment, useCallback, useEffect, useRef, useState } from 'react' import { paintCss } from './inks.js' import { CHARACTERS, PICKER_SHAPES, PICKER_COLUMNS, CHARACTER_COLORS, SHAPES, hasShape, shapeOf, bodyOf, maskBox, holes, nextMarkSerial } from './characters.js' +@@ -199,6 +199,16 @@ export function BotSettingsForm({ bot, roster, actions, run }) { + const timer = setTimeout(() => setConfirming(false), 4000) + return () => clearTimeout(timer) + }, [confirming]) ++ if (bot.remote) { ++ const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot") ++ return h('div', { className: 'bt-bs', 'data-flybot': 'remote-bot-settings' }, ++ h('div', { className: 'bt-bs-hero' }, ++ h(BotMark, { bot, size: 64, live: true }), ++ h('div', { className: 'bt-bs-who' }, ++ h('span', { className: 'bt-bs-name' }, bot.name), ++ h('span', { className: 'bt-bs-sub' }, who))), ++ h(FlyBotDetails, { bot, roster, actions })) ++ } + const isMainBot = isMainOf(roster, bot.id) + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 + const isTile = roster.mainBotId === bot.id @@ -246,6 +246,7 @@ export function BotSettingsForm({ bot, roster, actions, run }) { isTile ? null : h(Card, { title: t('Sidebar') }, h(SwitchRow, { label: t('Pin'), hint: t('Kept above the other chats.'), on: bot.pinned === true, onChange: pinned => run(actions.setFlags(bot.id, { pinned })) }), @@ -324,14 +341,69 @@ diff --git a/src/client/context-menu.js b/src/client/context-menu.js const ref = useRef(null) const [place, setPlace] = useState({ left: menu.x, top: menu.y }) useLayoutEffect(() => { -@@ -41,6 +41,7 @@ export function ContextMenu({ menu, roster, actions, leaving = false }) { - bot && !isMainBot ? [t('Make Main Bot'), StarLineIcon, () => actions.setMain(bot.id, true)] : null, - bot && isMainBot && mainCount > 1 ? [t('Remove Main Bot role'), StarLineIcon, () => actions.setMain(bot.id, false)] : null, - ], -+ bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [], - [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]], - [ - isMainBot ? null : [t('Hide from sidebar'), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], +@@ -27,28 +27,41 @@ export function ContextMenu({ menu, roster, actions, leaving = false }) { + const isMainBot = isMainOf(roster, menu.id) + const isTile = roster.mainBotId === menu.id + const mainCount = (roster.mainBotIds ?? []).length +- const groups = [ +- [ +- isTile ? null : [entry.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], +- [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], +- ], +- [ +- bot ? [t('Rename Bot'), PencilIcon, () => actions.renameBot(bot.id)] : null, +- bot ? [t('Edit Bot'), GearIcon, () => actions.openSettings('bots', bot.id)] : null, +- room ? [t('Group settings'), GearIcon, () => actions.openSettings('groups', room.id)] : null, +- room ? [t('Rename group'), PencilIcon, () => actions.renameBot(room.id)] : null, +- bot ? [t('Duplicate'), DuplicateIcon, async () => { const copy = await actions.duplicateBot(bot.id); actions.openSession(copy.id) }] : null, +- bot && !isMainBot ? [t('Make Main Bot'), StarLineIcon, () => actions.setMain(bot.id, true)] : null, +- bot && isMainBot && mainCount > 1 ? [t('Remove Main Bot role'), StarLineIcon, () => actions.setMain(bot.id, false)] : null, +- ], +- [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]], +- [ +- isMainBot ? null : [t('Hide from sidebar'), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], +- isMainBot ? null : [room ? t('Delete group') : t('Delete'), TrashIcon, () => (bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id)), true], +- ], +- ].map(group => group.filter(Boolean)).filter(group => group.length > 0) ++ const groups = bot?.remote ++ ? [ ++ [ ++ [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], ++ ], ++ [ ++ [t('Bot settings'), GearIcon, () => actions.openSettings('bots', bot.id)], ++ ], ++ pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), ++ [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]], ++ ] ++ : [ ++ [ ++ isTile ? null : [entry.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], ++ [t('Mark as Unread'), UnreadIcon, () => actions.markUnread(menu.id)], ++ ], ++ [ ++ bot ? [t('Rename Bot'), PencilIcon, () => actions.renameBot(bot.id)] : null, ++ bot ? [t('Edit Bot'), GearIcon, () => actions.openSettings('bots', bot.id)] : null, ++ room ? [t('Group settings'), GearIcon, () => actions.openSettings('groups', room.id)] : null, ++ room ? [t('Rename group'), PencilIcon, () => actions.renameBot(room.id)] : null, ++ bot ? [t('Duplicate'), DuplicateIcon, async () => { const copy = await actions.duplicateBot(bot.id); actions.openSession(copy.id) }] : null, ++ bot && !isMainBot ? [t('Make Main Bot'), StarLineIcon, () => actions.setMain(bot.id, true)] : null, ++ bot && isMainBot && mainCount > 1 ? [t('Remove Main Bot role'), StarLineIcon, () => actions.setMain(bot.id, false)] : null, ++ ], ++ bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [], ++ [[t('Copy conversation ID'), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]], ++ [ ++ isMainBot ? null : [t('Hide from sidebar'), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], ++ isMainBot ? null : [room ? t('Delete group') : t('Delete'), TrashIcon, () => (bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id)), true], ++ ], ++ ] ++ const shown = groups.map(group => group.filter(Boolean)).filter(group => group.length > 0) + return h('div', { ref, className: 'bt-menu', role: 'menu', 'aria-label': t('{name} actions', { name: bot?.name ?? room.name }), style: place, 'data-leaving': leaving || undefined }, +- groups.map((group, index) => h(Fragment, { key: index }, ++ shown.map((group, index) => h(Fragment, { key: index }, + index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, + group.map(([label, Icon, run, danger]) => h('button', { + key: label, type: 'button', role: 'menuitem', className: danger ? 'bt-danger' : undefined, @@ -92,3 +92,4 @@ function AgentMenu({ menu, roster, actions, leaving }) { onClick: () => { actions.closeMenu(); void Promise.resolve(run()).catch(error => console.warn('[ds-bot]', error)) }, }, h(Icon), label))))) @@ -1131,6 +1203,23 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js .bt-ustack{min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702),82%);display:flex;flex-direction:column;align-items:flex-end;gap:8px} .bt-ubody{max-width:100%;box-sizing:border-box;background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);white-space:pre-wrap;word-break:break-word} .bt-ubody a{color:inherit} +diff --git a/test/looks.test.js b/test/looks.test.js +--- a/test/looks.test.js ++++ b/test/looks.test.js +@@ -40,7 +40,12 @@ test('Bot settings is the only settings entry; nothing is called Team settings', + assert.match(text('palette.js'), /title: t\('Bot settings'\), run: \(\) => actions\.openSettings\('general'\)/) + // One Bot's own entries open that Bot, so they read Edit Bot instead. + assert.match(text('context-menu.js'), /\[t\('Edit Bot'\), GearIcon, \(\) => actions\.openSettings\('bots', bot\.id\)\]/) +- assert.match(text('details-drawer.js'), /'aria-label': bot \? t\('Edit Bot'\) : t\('Group settings'\), title: bot \? t\('Edit Bot'\) : t\('Group settings'\)/) ++ assert.match(text('context-menu.js'), /bot\?\.remote/) ++ assert.match(text('context-menu.js'), /\[t\('Bot settings'\), GearIcon, \(\) => actions\.openSettings\('bots', bot\.id\)\]/) ++ assert.match(text('details-drawer.js'), /bot\.remote \? t\('Bot settings'\) : t\('Edit Bot'\)/) ++ assert.match(text('bot-settings.js'), /if \(bot\.remote\)/) ++ assert.match(text('bot-settings.js'), /Friend's Bot/) ++ assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) + // A group chat's entries open its own settings the same way. + assert.match(text('context-menu.js'), /\[t\('Group settings'\), GearIcon, \(\) => actions\.openSettings\('groups', room\.id\)\]/) + assert.ok(!text('mark.js').includes('WhaleLogo')) diff --git a/test/mention-rule.test.js b/test/mention-rule.test.js --- a/test/mention-rule.test.js +++ b/test/mention-rule.test.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9fbe11c0e..95485a654 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1212,26 +1212,10 @@ 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} -@@ -2832,249 +2832,131 @@ function installCompiledStyles(text, tag) { - } - for (const name of list) names.add(name); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { +@@ -2848,233 +2848,115 @@ function installCompiledStyles(text, tag) { + observer.observe(document.head, { childList: true }); + document.addEventListener("load", onLoad, true); + return () => { - observer.disconnect(); - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); @@ -1364,22 +1348,6 @@ 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 next = compileFragmentSelectors(text, names); -+ if (style.textContent !== next) style.textContent = next; -+ }; -+ render(); -+ let timer; -+ const schedule = () => { -+ clearTimeout(timer); -+ timer = setTimeout(render, 120); -+ }; -+ const onLoad = (event) => { -+ if (event.target instanceof HTMLLinkElement) schedule(); -+ }; -+ const observer = new MutationObserver(schedule); -+ observer.observe(document.head, { childList: true }); -+ document.addEventListener("load", onLoad, true); -+ return () => { + observer.disconnect(); + document.removeEventListener("load", onLoad, true); + clearTimeout(timer); @@ -6022,7 +5990,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,22 +7982,22 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,26 +7982,45 @@ function AvatarEditor({ bot, actions, run, onClose }) { function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -6053,6 +6021,29 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); + }, [confirming]); ++ if (bot.remote) { ++ const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot"); ++ return (0, import_react19.createElement)( ++ "div", ++ { className: "bt-bs", "data-flybot": "remote-bot-settings" }, ++ (0, import_react19.createElement)( ++ "div", ++ { className: "bt-bs-hero" }, ++ (0, import_react19.createElement)(BotMark, { bot, size: 64, live: true }), ++ (0, import_react19.createElement)( ++ "div", ++ { className: "bt-bs-who" }, ++ (0, import_react19.createElement)("span", { className: "bt-bs-name" }, bot.name), ++ (0, import_react19.createElement)("span", { className: "bt-bs-sub" }, who) ++ ) ++ ), ++ (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }) ++ ); ++ } + const isMainBot = isMainOf(roster, bot.id); + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; + const isTile = roster.mainBotId === bot.id; @@ -8011,112 +8011,113 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); @@ -8482,9 +8473,10 @@ diff --git a/client.js b/client.js - (0, import_react28.createElement)( + (0, import_react29.createElement)( SetSection, - { key: "bots", title: t("Bots · {count}", { count: roster.bots.length }) }, +- { key: "bots", title: t("Bots · {count}", { count: roster.bots.length }) }, - roster.bots.map((bot) => (0, import_react28.createElement)( -+ roster.bots.map((bot) => (0, import_react29.createElement)( ++ { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) }, ++ roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react29.createElement)( "button", { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) }, - (0, import_react28.createElement)( @@ -8697,7 +8689,25 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10901,20 +10901,21 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10884,7 +10884,16 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { + const isMainBot = isMainOf(roster, menu.id); + const isTile = roster.mainBotId === menu.id; + const mainCount = (roster.mainBotIds ?? []).length; +- const groups = [ ++ const groups = bot?.remote ? [ ++ [ ++ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] ++ ], ++ [ ++ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)] ++ ], ++ pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), ++ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] ++ ] : [ + [ + isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], + [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] +@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -8707,14 +8717,16 @@ diff --git a/client.js b/client.js isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })], isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true] ] - ].map((group) => group.filter(Boolean)).filter((group) => group.length > 0); +- ].map((group) => group.filter(Boolean)).filter((group) => group.length > 0); - return (0, import_react29.createElement)( ++ ]; ++ const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); + return (0, import_react30.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 }, - groups.map((group, index) => (0, import_react29.createElement)( - import_react29.Fragment, -+ groups.map((group, index) => (0, import_react30.createElement)( ++ shown.map((group, index) => (0, import_react30.createElement)( + import_react30.Fragment, { key: index }, - index > 0 ? (0, import_react29.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, @@ -9283,7 +9295,7 @@ diff --git a/client.js b/client.js - (0, import_react31.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), - hasStrip ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react31.createElement)(CloseIcon)) + (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react32.createElement)(ChevronLeftIcon)), -+ (0, import_react32.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), ++ (0, import_react32.createElement)("span", { className: "bt-subhead-title" }, bot ? bot.remote ? t("Bot settings") : t("Settings") : t("Group settings")), + hasStrip ? null : (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react32.createElement)(CloseIcon)) ), - (0, import_react31.createElement)( @@ -9304,8 +9316,8 @@ diff --git a/client.js b/client.js - bot && roster.browser === true && actions.openBrowser ? (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react31.createElement)(GlobeIcon)) : null, - bot || room2 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react31.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react31.createElement)(ShareIcon)) : null, - hasStrip ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react31.createElement)(CloseIcon)) -+ bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? t("Edit Bot") : t("Group settings"), title: bot ? t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => setView("settings") }, (0, import_react32.createElement)(GearIcon)) : null, -+ bot && roster.browser === true && actions.openBrowser ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react32.createElement)(GlobeIcon)) : null, ++ bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => setView("settings") }, (0, import_react32.createElement)(GearIcon)) : null, ++ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react32.createElement)(GlobeIcon)) : null, + bot || room2 ? (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react32.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react32.createElement)(ShareIcon)) : null, + hasStrip ? null : (0, import_react32.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react32.createElement)(CloseIcon)) ); @@ -9331,11 +9343,11 @@ diff --git a/client.js b/client.js - bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - editing ? (0, import_react31.createElement)("input", { + bot ? (0, import_react32.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), -+ editing ? (0, import_react32.createElement)("input", { ++ bot?.remote ? (0, import_react32.createElement)("div", { className: "bt-drawer-name" }, current) : editing ? (0, import_react32.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,55 +11535,55 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,55 +11535,58 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -9345,10 +9357,10 @@ diff --git a/client.js b/client.js setEditing(true); } }, current), - subtitle ? (0, import_react31.createElement)("span", { className: "bt-drawer-role" }, subtitle) : null -+ subtitle ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, subtitle) : null ++ subtitle ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react32.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ), - (0, import_react31.createElement)( -+ (0, import_react32.createElement)( ++ bot?.remote ? null : (0, import_react32.createElement)( "div", { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side }, - (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), @@ -9362,9 +9374,13 @@ diff --git a/client.js b/client.js { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move }, errorLine, - room2 && tab === "details" ? (0, import_react31.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, -+ room2 && tab === "details" ? (0, import_react32.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, - bot && tab === "details" ? [ +- bot && tab === "details" ? [ - (0, import_react31.createElement)( ++ room2 && tab === "details" ? (0, import_react32.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, ++ bot && bot.remote && tab === "details" ? [ ++ bot.remote.handle ? (0, import_react32.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null ++ ] : null, ++ bot && !bot.remote && tab === "details" ? [ + (0, import_react32.createElement)( "div", { key: "model", className: "bt-drawer-model" }, @@ -9403,7 +9419,7 @@ diff --git a/client.js b/client.js + (0, import_react32.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ] : null, - bot && tab === "browser" ? (0, import_react31.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null -+ bot && tab === "browser" ? (0, import_react32.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ++ bot && !bot.remote && tab === "browser" ? (0, import_react32.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 8cd531460..cdfa45ab0 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -168,6 +168,71 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js 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, +@@ -180,15 +180,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + return [ + h('div', { key: 'head', className: 'bt-subhead', 'data-move': move }, + h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Back to details'), onClick: () => setView('overview') }, h(ChevronLeftIcon)), +- h('span', { className: 'bt-subhead-title' }, bot ? t('Settings') : t('Group settings')), ++ h('span', { className: 'bt-subhead-title' }, bot ? (bot.remote ? t('Bot settings') : t('Settings')) : t('Group settings')), + hasStrip ? null : h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))), + h('div', { key: 'set-body', className: 'bt-drawer-body bt-set-body', 'data-move': move }, errorLine, + bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), + ] + } + const top = h('div', { className: 'bt-drawer-top', 'data-move': side }, +- bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': bot ? t('Edit Bot') : t('Group settings'), title: bot ? t('Edit Bot') : t('Group settings'), 'data-info-row': 'settings', onClick: () => setView('settings') }, h(GearIcon)) : null, +- bot && roster.browser === true && actions.openBrowser ++ bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': bot ? (bot.remote ? t('Bot settings') : t('Edit Bot')) : t('Group settings'), title: bot ? (bot.remote ? t('Bot settings') : t('Edit Bot')) : t('Group settings'), 'data-info-row': 'settings', onClick: () => setView('settings') }, h(GearIcon)) : null, ++ bot && !bot.remote && roster.browser === true && actions.openBrowser + ? h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Open browser'), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, h(GlobeIcon)) + : null, + bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, +@@ -213,22 +213,27 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + h('div', { key: 'id', className: 'bt-drawer-id', 'data-move': side }, + bot ? h(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== undefined), main: isMainBot, badge: false, gaze: true, pokeable: true }) + : h(RoomAvatar, { room, roster, size: 64 }), +- editing +- ? h('input', { +- className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), +- placeholder: room ? t('Name after members') : undefined, +- onChange: event => setDraftName(event.target.value), onBlur: saveName, +- onKeyDown: (event) => { if (event.key === 'Enter') void saveName(); if (event.key === 'Escape') { event.stopPropagation(); setEditing(false) } }, +- }) +- : h('button', { type: 'button', className: 'bt-drawer-name', 'aria-label': t('Rename {name}', { name: current }), title: t('Rename'), onClick: () => { setDraftName(room && !room.named ? '' : current); setEditing(true) } }, current), +- subtitle ? h('span', { className: 'bt-drawer-role' }, subtitle) : null), +- h('div', { key: 'tabs', ref: glide.box, className: 'bt-tabs', role: 'tablist', 'data-move': side }, ++ bot?.remote ++ ? h('div', { className: 'bt-drawer-name' }, current) ++ : editing ++ ? h('input', { ++ className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), ++ placeholder: room ? t('Name after members') : undefined, ++ onChange: event => setDraftName(event.target.value), onBlur: saveName, ++ onKeyDown: (event) => { if (event.key === 'Enter') void saveName(); if (event.key === 'Escape') { event.stopPropagation(); setEditing(false) } }, ++ }) ++ : h('button', { type: 'button', className: 'bt-drawer-name', 'aria-label': t('Rename {name}', { name: current }), title: t('Rename'), onClick: () => { setDraftName(room && !room.named ? '' : current); setEditing(true) } }, current), ++ subtitle ? h('span', { className: 'bt-drawer-role' }, subtitle) : bot?.remote ? h('span', { className: 'bt-drawer-role' }, t("Friend's Bot")) : null), ++ bot?.remote ? null : h('div', { key: 'tabs', ref: glide.box, className: 'bt-tabs', role: 'tablist', 'data-move': side }, + h('span', { ref: glide.thumb, className: 'bt-thumb', 'aria-hidden': true }), + tabs.map(key => h('button', { key, type: 'button', role: 'tab', className: 'bt-tab', 'aria-selected': tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))), + h('div', { key: `body:${tab}`, className: 'bt-drawer-body', 'data-move': move }, + errorLine, + room && tab === 'details' ? h(GroupOverview, { room, roster, actions, usage, useWork, openSettings: () => setView('settings') }) : null, +- bot && tab === 'details' ? [ ++ bot && bot.remote && tab === 'details' ? [ ++ bot.remote.handle ? h('div', { key: 'handle', className: 'bt-muted' }, bot.remote.handle) : null, ++ ] : null, ++ bot && !bot.remote && tab === 'details' ? [ + h('div', { key: 'model', className: 'bt-drawer-model' }, + h('div', { className: 'bt-section-title' }, t('Model')), + h(ModelMenu, { bot, roster, actions, run, wide: true }), +@@ -245,5 +245,5 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + h('div', { className: 'bt-kv' }, h('span', null, bot.name), h('span', null, usage))) : null, + h(ScheduleSummary, { key: 'routines', bot, actions }), + ] : null, +- bot && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null)] ++ bot && !bot.remote && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : 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 diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 698cd5671..214aa0406 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -144,6 +144,17 @@ diff --git a/src/client/settings.js b/src/client/settings.js const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { +@@ -112,8 +112,8 @@ function BotsPage({ roster, actions, run }) { + ? h(CreateBot, { key: 'create', roster, actions, inline: true, onBack: () => setCreating(false), onCreated: bot => actions.openSettings('bots', bot.id) }) + : h('div', { key: 'new', className: 'bt-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => setCreating(true) }, h(PlusIcon), t('New Bot'))), +- h(SetSection, { key: 'bots', title: t('Bots · {count}', { count: roster.bots.length }) }, +- roster.bots.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row', onClick: () => actions.openSettings('bots', bot.id) }, ++ h(SetSection, { key: 'bots', title: t('Bots · {count}', { count: roster.bots.filter(bot => !bot.remote).length }) }, ++ roster.bots.filter(bot => !bot.remote).map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row', onClick: () => actions.openSettings('bots', bot.id) }, + h('span', { className: 'bt-set-bot' }, + h(BotAvatar, { bot, size: 28, badge: false, live: false }), + h('span', { className: 'bt-row-title' }, bot.name), @@ -181,7 +181,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null return h('div', { className: 'bt-settings-layer', 'data-leaving': leaving || undefined }, diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index f1f03e108..389d30b0e 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -35,7 +35,8 @@ "src/client/icons.js", "src/client/new-chat.js", "src/client/palette.js", - "test/mention-rule.test.js" + "test/mention-rule.test.js", + "test/looks.test.js" ], "0005-social-seams": [ "src/host/channel.js", diff --git a/plugins/social/client.js b/plugins/social/client.js index e73a90c39..bba681de3 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -48,6 +48,8 @@ window.__ModuleLoader__.load({ .fs-icon[aria-expanded=true]{background:var(--bt-active,rgba(119,119,119,.17));color:var(--bt-ink,#141414)} .fs-body{display:flex;flex-direction:column;gap:2px;padding:2px 0 0} .fs-empty,.fs-hint{padding:4px 10px;font-size:12px;line-height:17px;color:var(--bt-ink-2,rgba(20,20,20,.6));overflow-wrap:anywhere} +.fs-join-as{align-items:baseline;flex-wrap:wrap;overflow-wrap:normal;word-break:normal} +.fs-join-as-label{flex:none;white-space:nowrap;overflow-wrap:normal;word-break:keep-all} .fs-empty{padding:6px 10px 8px;font-size:13px;line-height:18px} .fs-label{padding:8px 10px 2px;font-size:11px;line-height:14px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;color:var(--bt-ink-3,rgba(20,20,20,.36));display:flex;align-items:center;gap:6px} .fs-home-head{display:flex;align-items:center;gap:4px;min-width:0;padding:2px 4px 0 0} @@ -171,6 +173,9 @@ window.__ModuleLoader__.load({ .fs-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} .fs-wait{display:flex;flex-direction:column;gap:8px;flex:1 1 auto;min-height:0;overflow:hidden} .fs-wait-count{flex:none;padding:0} +.fs-wait-code{flex:none;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 0 2px} +.fs-wait-code-label{font-size:11px;line-height:14px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;color:var(--bt-ink-3,rgba(20,20,20,.36))} +.fs-wait-code-digits{font-size:28px;line-height:34px;font-weight:600;letter-spacing:.28em;font-variant-numeric:tabular-nums;color:var(--bt-ink,#141414)} .fs-wait-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px} .fs-wait-foot{display:flex;align-items:stretch;gap:8px;flex:none} .fs-wait-foot .fs-btn{height:36px} @@ -414,6 +419,45 @@ window.__ModuleLoader__.load({ return (bots ?? []).some(bot => bot.handle === handle) } + function faceWaitCode(room, typed) { + const fromRoom = String(room?.joinCode || room?.code || '').replace(/\D/g, '').slice(0, 6) + if (fromRoom.length === 6) return fromRoom + const fromTyped = String(typed || '').replace(/\D/g, '').slice(0, 6) + return fromTyped.length === 6 ? fromTyped : '' + } + function FaceWaitCode({ code } = {}) { + const digits = String(code || '').replace(/\D/g, '').slice(0, 6) + if (digits.length !== 6) return null + return h('div', { className: 'fs-wait-code', 'data-flybot': 'face-wait-code', 'aria-label': `Room number ${digits}` }, + h('div', { className: 'fs-wait-code-label' }, 'Room number'), + h('div', { className: 'fs-wait-code-digits', 'data-flybot': 'face-wait-code-digits' }, digits)) + } + function shownHoldText(value) { + const raw = String(value ?? '') + const mark = 'FLYBOT-FACE-HUMAN' + const at = raw.indexOf(mark) + if (at < 0) return raw + const start = raw.indexOf('{', at) + if (start < 0) return raw.slice(0, at).trim() + let depth = 0 + let end = -1 + for (let i = start; i < raw.length; i += 1) { + const ch = raw[i] + if (ch === '{') depth += 1 + else if (ch === '}') { + depth -= 1 + if (depth === 0) { end = i; break } + } + } + if (end < 0) return raw.slice(0, at).trim() + try { + const parsed = JSON.parse(raw.slice(start, end + 1)) + const text = typeof parsed?.text === 'string' ? parsed.text.trim() : '' + if (text) return text + } catch { /* fall through */ } + return raw.slice(0, at).trim() + } + // Separate Face-to-face group screen (#92): pick Bots, join by number, live waiting room. function FaceToFaceGroup({ actions, onClose, roster, remoteChats } = {}) { const [step, setStep] = useState('setup') @@ -508,7 +552,7 @@ window.__ModuleLoader__.load({ setBusy(false) return } - setRoom({ ...last, mineHandles: mine }) + setRoom({ ...last, mineHandles: mine, joinCode: last.joinCode || digits }) setStep('waiting') setBusy(false) } @@ -554,6 +598,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-name' }, 'Face-to-face group'), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: () => onClose?.() }, 'Close')), waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, + h(FaceWaitCode, { code: faceWaitCode(room, code) }), h('div', { className: 'fs-hint fs-wait-count', 'data-flybot': 'face-wait-count', 'data-tick': tick }, candidate ? (room.expiresAt ? countdown(room.expiresAt) : 'Anyone with the number can still join.') : 'The room is open. The number is no longer valid.'), @@ -576,7 +621,7 @@ window.__ModuleLoader__.load({ type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter', disabled: !!busy || !room.roomId, onClick: enter, }, 'Enter chat'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' }, - h('div', { className: 'fs-hint' }, 'Choose which of your Bots to bring, then type the same 6-digit number as the people next to you. You join at once — there is no preview.'), + h('div', { className: 'fs-hint' }, "Choose which of your Bots to bring, then type the same 6-digit number as the people next to you. You'll wait together until someone enters the chat."), h('ul', { className: 'fs-tiles fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' }, bots.map(bot => h(FaceTile, { key: bot.id, @@ -710,7 +755,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-name' }, WAITING_OTHERS)) } const { from, to } = holdParties(item) - const text = item.text || item.summary || '' + const text = shownHoldText(item.text || item.summary || '') const long = text.length > 160 || text.split(/\n/).length > 3 return h('div', { className: 'fs-hold fs-chat-approval', 'data-flybot': 'chat-approval', 'data-role': 'owner', role: 'region', 'aria-label': WAITING_OWNER }, h('div', { className: 'fs-label' }, WAITING_OWNER), @@ -963,6 +1008,7 @@ window.__ModuleLoader__.load({ const expiresAt = snapshot?.expiresAt || face?.expiresAt if (candidate) { return h('div', { className: 'fs-wait-overview', 'data-flybot': 'face-overview', 'data-wait': true }, + h(FaceWaitCode, { code: faceWaitCode(snapshot || face || room) }), 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' }, @@ -1149,7 +1195,8 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-line' }, h('input', { className: 'fs-input', value: code, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }), h('button', { type: 'submit', className: 'fs-btn', disabled: busy || !code.trim() }, 'Join')), - mine.length ? h('label', { className: 'fs-line fs-hint', 'data-flybot': 'join-as' }, 'Talk through ', + mine.length ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as' }, + h('span', { className: 'fs-join-as-label', 'data-flybot': 'talk-through' }, 'Talk through '), h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots talks', onChange: e => setJoiner(e.target.value) }, mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))), ' (not shared unless you invite them to it)') : null), @@ -1244,7 +1291,7 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } - function FriendChatSettings({ bot } = {}) { + function FriendChatSettings({ bot, actions } = {}) { const remote = bot?.remote const home = remote?.home const handle = remote?.handle @@ -1280,6 +1327,8 @@ window.__ModuleLoader__.load({ // social/hide-remote already leaves every room holding this Bot; a second // removal from here would race it and show "not in the room" after a good Remove. await call('social/hide-remote', { handle }) + actions?.closeSettings?.() + actions?.closeOverlay?.() } catch (e) { setError(errorText(e)) } finally { setBusy(false) } } return h('div', { className: 'fs fs-card', 'data-flybot': 'friend-chat-settings' }, @@ -1301,11 +1350,11 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } - function BotSocialDetails({ bot } = {}) { + function BotSocialDetails({ bot, actions } = {}) { useStyles() if (bot?.remote) { return h('div', { 'data-flybot': 'bot-social-details' }, - h(FriendChatSettings, { bot })) + h(FriendChatSettings, { bot, actions })) } return h('div', { 'data-flybot': 'bot-social-details' }, h(InviteToBot, { bot }), diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 37be2a8e8..bdb125c2e 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -48,6 +48,7 @@ import { platformJoinText, humanFaceWho, faceRoomTitle, + parseFaceHuman, } from './face-rooms.js' import { hideRemoteInSettings, @@ -80,6 +81,12 @@ const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixe export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback // 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) +// Owner cards never show the FLYBOT-FACE-HUMAN wire envelope (#159). +export function approvalDraftText(value) { + const raw = draftText(value) + const human = parseFaceHuman(raw) + return human ? draftText(human.text) : raw +} // 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 } = {}) { @@ -88,7 +95,7 @@ export function approvalView(item, { localName } = {}) { if (!id) return undefined const to = item.message?.to const payload = item.message?.payload - const text = draftText(payload && typeof payload === 'object' ? payload.text : payload) || draftText(item.summary) + const text = approvalDraftText(payload && typeof payload === 'object' ? payload.text : payload) || approvalDraftText(item.summary) const kind = String(item.kind ?? '') const viaComposer = item.via === 'composer' || item.message?.via === 'composer' @@ -653,13 +660,16 @@ export function createSocial(service, config = {}, { client: given, api: givenAp return keys } // One-time upgrade: remotes already on the server roster are treated as inbound so - // reciprocal friends are not dropped when inboundBots was empty (Codex P1). + // reciprocal friends are not dropped when inboundBots was empty (Codex P1). Skip + // reverse-friends of invites we created and face-room-only remotes (#138 / #161). function backfillInboundFromRoster(mappedHomes) { if (settings.inboundBackfilled) return settings.inboundBots ??= {} + const outgoing = outgoingPeerHandles() for (const bot of (mappedHomes ?? []).flatMap(entry => entry.bots ?? [])) { const key = norm(bot.handle) if (!key || key === '@') continue + if (outgoing.has(key)) continue if (settings.roomBots?.[key]) continue settings.inboundBots[key] = true } @@ -670,8 +680,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const key = norm(bot.handle) if (!key) return false if (declinedRemotes.has(key)) return false - if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true + // Outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161). if (outgoingPeerHandles().has(key)) return false + if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true if (settings.roomBots?.[key]) return false return true } @@ -2094,7 +2105,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const room = await applyFaceSnapshot(snapshot, { asBot: ready.handle }) saveSettings() if (room) await setRoomGrant(ready.id, room, { faceRoomId: snapshot.roomId, name: faceRoomTitle(snapshot, { code: digits, room }) }) - return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle } }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members } + return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle }, joinCode: digits }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members, joinCode: digits } } catch (error) { // A brand-new room-only identity that never made it into a room must not block retry // (and must not linger as a reachable home member). @@ -2170,11 +2181,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { const sent = await faceApi.send(serverId, { text: outbound.text, botId: outbound.botId }) if (sent?.status === 'pending_approval') { - pendingCache.at = 0 - if (outbound.human && sent.approvalId) { + // Auto-send is the only owner-line auto-approve. Otherwise the in-chat card + // stays until Approve / Always approve / Reject (#159). Decide first so a + // poll cannot flash the FLYBOT-FACE-HUMAN envelope. + if (outbound.human && sent.approvalId && await faceAutoApprove().catch(() => false)) { await confirmedCall('decide_approval', { approval_id: sent.approvalId, decision: 'approved' }) .catch(error => warn('face-room owner line not auto-approved', error)) } + pendingCache.at = 0 } } catch (error) { warn('face-room send failed', error) @@ -2187,7 +2201,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (payload?.poll !== false) await pollFaceRooms().catch(() => {}) const rooms = Object.entries(settings.faceRooms).map(([faceRoomId, meta]) => { const room = flybot.rooms.ofFace?.(faceRoomId) - return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate' } + return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode } }).filter(Boolean) const liveIds = new Set(rooms.map(room => room.faceRoomId).filter(Boolean)) for (const past of settings.pastFaceRooms ?? []) { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4d1e3a111..69c3d5eb8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8,7 +8,7 @@ import { test } from 'node:test' import { apply as applyDsBot } from 'ds-bot' import { fakeContext } from './fake-ctx.js' import { apply as applySocial, inject, name } from '../index.js' -import { approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' +import { approvalDraftText, approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js' import { lookFromServer, lookToServer, lookFingerprint, hexOfColor } from '../src/look.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' @@ -1093,6 +1093,20 @@ test('approvalView: outbound draft text is sanitized, friend requests stay out', message: { to: { handle: '@chief.bob.home', display_name: 'Chief' }, payload: { text: 'hi' }, via: 'composer' }, }, { localName: () => 'Helper' }) assert.equal(yours.from, 'You') + const packed = encodeFaceHuman({ who: 'BoxA', text: 'hello from the owner' }) + assert.equal(approvalDraftText(packed), 'hello from the owner') + assert.equal(approvalDraftText(`To the room: ${packed}`), 'hello from the owner') + assert.doesNotMatch(approvalDraftText(packed), /FLYBOT-FACE-HUMAN/) + const faceHold = approvalView({ + approval_id: 'apr-face-human', + kind: 'face_room_message', + sender_kind: 'human', + summary: `To the room: ${packed}`, + message: { payload: { text: packed } }, + }) + assert.equal(faceHold.from, 'You') + assert.equal(faceHold.text, 'hello from the owner') + assert.doesNotMatch(faceHold.text, /FLYBOT-FACE-HUMAN/) }) test('Friends\' Bots (#69): a pending approval shows a badge and a one-line jump row', () => { @@ -1304,7 +1318,7 @@ function withFaceRooms(server) { return new Response(JSON.stringify(room), { status: 200 }) } if (init.method === 'POST' && path.endsWith('/messages')) { - if (body.text === 'hold me') { + if (body.text === 'hold me' || (typeof body.text === 'string' && body.text.includes('hold me'))) { return new Response(JSON.stringify({ message_id: 'fmd_h', status: 'pending_approval', approval_id: 'apr-face-1', from: room.members[0] }), { status: 202 }) } room.event_cursor += 1 @@ -1535,6 +1549,59 @@ test('a spoofed FLYBOT-FACE-HUMAN who is ignored; the room shows the authenticat assert.doesNotMatch(String(spoofed.who), /Alice/) }) +test('#159: owner face line stays held unless face Auto-send is on', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + const packed = encodeFaceHuman({ who: 'BoxA', text: 'hold me' }) + let queue = [{ + approval_id: 'apr-face-1', + kind: 'face_room_message', + status: 'pending', + sender_kind: 'human', + bot_id: 'm1', + room_id: 'frm_123456', + message: { payload: { text: packed } }, + summary: `To the room: ${packed}`, + }] + server.tools.list_approvals = () => ({ approvals: queue }) + server.tools.decide_approval = a => { + if (a.confirm_code === undefined) return { status: 'confirmation_required', confirm_code: 'C0DE02' } + queue = queue.filter(item => item.approval_id !== a.approval_id) + return { approval_id: a.approval_id, decision: a.decision } + } + let fanout + const origScope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = origScope(name) + const origOn = scoped.rooms.onFaceLine.bind(scoped.rooms) + scoped.rooms.onFaceLine = (fn) => { + fanout = fn + return origOn(fn) + } + return scoped + } + 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' }) + const room = ctx.flybot.rooms.ofFace('frm_123456') + assert.equal(typeof fanout, 'function') + await fanout(room, { authorKind: 'human', who: 'Danyang', text: 'hold me', authorId: 'owner-1' }) + const status = (await call('flybot/social/status', {})).value + assert.equal(status.pendingApprovals, 1) + assert.equal(status.approvals[0].text, 'hold me') + assert.doesNotMatch(String(status.approvals[0].text), /FLYBOT-FACE-HUMAN/) + assert.equal(server.calls.some(c => c.name === 'decide_approval'), false, 'Auto-send off: owner line stays in the approval card') + await call('flybot/social/face-auto', { on: true }) + await fanout(room, { authorKind: 'human', who: 'Danyang', text: 'hold me', authorId: 'owner-1' }) + assert.equal(server.calls.some(c => c.name === 'decide_approval' && c.args?.decision === 'approved'), true) +}) + test('face-to-face send pending_approval uses decide_approval', async (t) => { const { ctx, call, stop } = await team() t.after(stop) @@ -2052,7 +2119,7 @@ test('Face-to-face waiting overview is a tile grid with Leave and Enter chat', ( 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' }, + face: { roomId: 'frm_1', status: 'candidate', expiresAt: '2099-01-01T00:00:00.000Z', joinCode: '482913' }, participants: [ { handle: '@a.me.home', displayName: 'Chief', ownerName: 'Me', remote: false, id: 'a' }, { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', remote: true }, @@ -2066,6 +2133,8 @@ test('Face-to-face waiting overview is a tile grid with Leave and Enter chat', ( 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.ok(tree.some(n => n.props['data-flybot'] === 'face-wait-code')) + assert.equal(tree.find(n => n.props['data-flybot'] === 'face-wait-code-digits')?.children.join(''), '482913') 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) }) @@ -2133,6 +2202,19 @@ test('#126: a face-room hold shows in that chat with a readable name', () => { assert.ok(tree.some(n => n.props.className === 'fs-draft' && n.children.includes('hello there'))) }) +test('#159: a face-room hold never shows the FLYBOT-FACE-HUMAN envelope', () => { + const packed = encodeFaceHuman({ who: 'BoxA', text: 'hello from the owner' }) + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([[], [], '']) + const tree = walk(plugin.ChatApproval({ + item: { id: 'apr-face', kind: 'face_room_message', from: 'You', toName: 'the room', text: packed, summary: `To the room: ${packed}` }, + canDecide: true, + })) + const draft = tree.find(n => n.props.className === 'fs-draft') + assert.equal(draft?.children.join(''), 'hello from the owner') + assert.equal(tree.some(n => /FLYBOT-FACE-HUMAN/.test(String(n.children ?? ''))), false) +}) + test('Face-to-face rooms list polls Bot Social for Past rooms', () => { const calls = [] const { walk, pluginOf } = loadSocialClient(async (endpoint, payload) => { @@ -2366,6 +2448,8 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.ok(picker.some(n => n.props['data-flybot'] === 'face-group')) assert.ok(picker.some(n => n.props['data-flybot'] === 'face-pick' && /\bfs-tiles\b/.test(n.props.className))) assert.ok(picker.some(n => n.props['data-flybot'] === 'face-join')) + assert.equal(picker.some(n => /no preview/i.test(String(n.children ?? ''))), false) + assert.ok(picker.some(n => /wait together/i.test(String(n.children ?? '')))) const pickAvatars = picker.filter(n => n.props['data-flybot'] === 'face-avatar') assert.equal(pickAvatars.length, 2) const chiefMark = picker.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a') @@ -2419,6 +2503,8 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch } const wait = setup(['waiting', bots, ['a'], '123456', false, null, [{ botId: 'b', name: 'Writer', text: 'This Bot cannot join that room.' }], room, 0]) assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait')) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-code')) + assert.equal(wait.find(n => n.props['data-flybot'] === 'face-wait-code-digits')?.children.join(''), '123456') assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-roster' && /\bfs-tiles\b/.test(n.props.className))) const mine = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@a.me.home') const peer = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@coder.bob.home') @@ -3539,7 +3625,7 @@ test('#140: new friends default Auto-send off; settings report false', async (t) assert.equal(social.approvalFor('a', '@bob.home'), 'hold') }) -test('#138 upgrade: backfill inbound keeps reciprocal remotes already on the roster', async (t) => { +test('#138 upgrade: backfill inbound keeps reciprocal remotes, not the joiner of our invite', async (t) => { const { ctx, call, home, stop } = await team() t.after(stop) const server = mockServer() @@ -3567,11 +3653,45 @@ test('#138 upgrade: backfill inbound keeps reciprocal remotes already on the ros await grantTeam(ctx.flybot) await social.registerAll() const listed = (await call('flybot/social/remote-bots', { fresh: true })).value - assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@chief.bob.home'), true, 'reciprocal Bot kept on upgrade backfill') - assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true) + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@chief.bob.home'), false, 'the Bot that joined our invite is not granted to us') + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true, 'reciprocal Bot kept on upgrade backfill') const saved = JSON.parse(readFileSync(settingsPath, 'utf8')) assert.equal(saved.inboundBackfilled, true) - assert.equal(saved.inboundBots['@chief.bob.home'], true) + assert.equal(saved.inboundBots['@chief.bob.home'], undefined) + assert.equal(saved.inboundBots['@coder.bob.home'], true) +}) + +test('#161: invite accept does not list the joiner Chief on a fresh install', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_bot_grants = () => ({ + grants: [{ id: 'g-rev', grantee: 'Chief@chief.bob.home', name: 'Chief@chief.bob.home', relationship_id: 'r1' }], + }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [ + { bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief' }, + { bot_id: 'x2', handle: '@coder.bob.home', display_name: 'Coder' }, + ], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({})) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + 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() + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(listed.homes[0]?.bots.some(bot => bot.handle === '@chief.bob.home'), false, 'inviter must not see the joiner Chief') + assert.equal(listed.homes[0]?.bots.some(bot => bot.handle === '@coder.bob.home'), true) + const saved = JSON.parse(readFileSync(settingsPath, 'utf8')) + assert.equal(saved.inboundBots?.['@chief.bob.home'], undefined) }) test('#140: respond Auto-send follows the accepted relationship, not the first roster home', async (t) => { @@ -3623,9 +3743,31 @@ test('#136: + Copy my handle copies the home; Auto-send starts unchecked; Join-a assert.equal(box.props.checked, false) const add = walk(pluginOf([null, '', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a']).AddFriend({})) assert.ok(add.some(n => n.props['data-flybot'] === 'join-as')) + assert.match(add.find(n => n.props['data-flybot'] === 'join-as').props.className, /fs-join-as/) + assert.match(add.find(n => n.props['data-flybot'] === 'talk-through').children.join(''), /Talk through/) assert.match(add.find(n => n.props['data-flybot'] === 'join-as').children.join(''), /not shared/i) }) +test('#160: Remove this Bot closes the settings overlay', async () => { + const hidden = [] + const closed = [] + const { walk, pluginOf, sandbox } = loadSocialClient(async (endpoint, payload) => { + if (endpoint === 'social/hide-remote') { hidden.push(payload); return { handle: payload.handle, hidden: true, homes: [] } } + if (endpoint === 'social/settings') return { autoAccept: {} } + return {} + }) + sandbox.confirm = () => true + const tree = walk(pluginOf([false, false, false, '']).FriendChatSettings({ + bot: { name: 'Helper', remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, + actions: { closeSettings: () => closed.push('settings'), closeOverlay: () => closed.push('overlay') }, + })) + tree.find(n => n.props['data-flybot'] === 'remove-friend-bot').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(hidden.map(p => p.handle), ['@coder.bob.home']) + assert.ok(closed.includes('settings')) + assert.ok(closed.includes('overlay')) +}) + test('#139: Remove asks once; cancelling leaves the Bot', () => { const asked = [] const hidden = [] @@ -3650,6 +3792,8 @@ test('#138: join success copy names the person and the Bot', () => { assert.match(src, /shared \$\{shared\} with you/) assert.match(src, /Talk through /) assert.match(src, /not shared unless you invite them to it/) + assert.match(src, /\.fs-join-as-label\{[^}]*white-space:nowrap/) + assert.doesNotMatch(src, /there is no preview/) }) test('#137: look helpers map preset, emoji, data URL, and named color', () => {