From 14006ba7b714e592a01b6cc4b77574c160bb57b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 09:56:26 +0000 Subject: [PATCH 1/3] Polish social UI for issue #173 Sidebar and + menu consistency, sharing settings layout, English Chief greeting default, tab title sync, and friend-chat empty hint. Regenerate ds-bot patches from core. Co-authored-by: Danyang Chen --- changelog.d/173-social-ui-polish.changed.md | 1 + core/client.js | 57 +++++++- core/src/client/bot-settings.js | 2 +- core/src/client/brand.js | 18 +++ core/src/client/header.js | 15 +++ core/src/client/i18n.js | 2 + core/src/client/index.js | 15 ++- core/src/client/new-chat.js | 2 +- core/src/client/sidebar.js | 5 +- core/src/client/styles.js | 3 + core/src/host/channel.js | 2 +- core/src/host/roster.js | 2 +- core/test/new-chat.test.js | 4 +- patches/ds-bot/0001-core-fixes.patch | 11 +- patches/ds-bot/0002-flybot-api-host.patch | 10 +- patches/ds-bot/0003-flybot-api-client.patch | 44 +++++-- patches/ds-bot/0005-social-seams.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 138 +++++++++++++------- patches/ds-bot/0012-room-membership.patch | 17 ++- patches/ds-bot/files/src/client/brand.js | 18 +++ patches/ds-bot/files/test/new-chat.test.js | 4 +- plugins/social/client.js | 48 ++++--- plugins/social/src/mcp-client.js | 3 +- plugins/social/test/social.test.js | 21 ++- 24 files changed, 347 insertions(+), 105 deletions(-) create mode 100644 changelog.d/173-social-ui-polish.changed.md diff --git a/changelog.d/173-social-ui-polish.changed.md b/changelog.d/173-social-ui-polish.changed.md new file mode 100644 index 000000000..707ee37e6 --- /dev/null +++ b/changelog.d/173-social-ui-polish.changed.md @@ -0,0 +1 @@ +Sidebar friend rows use hover-only settings gear, two-line titles with the Friend's Bot marker visible, and Past rooms in sentence case. The + menu merges Add friend and Accept invite, uses Add Bot consistently, and Escape closes Add friend and Face-to-face dialogs. Bot Sharing settings get a section header; friend empty chats show a one-line hint; the tab title follows the active chat; Chief greets in English when the UI is English. ([#173](https://github.com/ThinkFlowLab/FlyBot/issues/173)) diff --git a/core/client.js b/core/client.js index 89a0383c7..3fcc3061a 100644 --- a/core/client.js +++ b/core/client.js @@ -316,6 +316,7 @@ var ZH = { "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", + "Send a message. Your friend approves unless Auto-send is on.": "发送消息。除非已开启自动发送,否则好友需要批准。", // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", @@ -441,6 +442,7 @@ var ZH = { // stays in English. "Add DSH Agent": "添加 DSH Agent", "Add Agent": "添加 Agent", + "Add Bot": "添加 Bot", "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", @@ -2715,6 +2717,8 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row:has(.bt-row-remote) .bt-row-title,.bt-row-wrap:has(.bt-row-remote) .bt-row-title{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +.bt-row:has(.bt-row-remote) .bt-row-name,.bt-row-wrap:has(.bt-row-remote) .bt-row-name{align-items:flex-start} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3410,6 +3414,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} the shell's new-session hero: no headline, no workspace or mode picker, and the composer stays docked at the bottom. */ [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} @@ -3643,6 +3648,21 @@ function brandTitle(title) { if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); return text; } +function sessionTabLabel(roster, sessionId) { + if (!roster || !sessionId) return ""; + const bot = roster.byId?.[sessionId]; + const room2 = roster.roomsById?.[sessionId]; + if (bot?.remote) { + return remoteBotTitle({ + displayName: bot.name, + ownerName: bot.remote.homeName || bot.remote.owner, + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); + } + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; +} function installBrandChrome() { if (typeof document === "undefined") return () => { }; @@ -6354,8 +6374,9 @@ var SIDEBAR_CSS = ` .bt-row-wrap{display:flex;align-items:center;width:100%;min-width:0} .bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0} -.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:.85;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -.bt-row-settings:hover,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} +.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} .bt-row-settings svg{width:15px;height:15px} `; @@ -6420,6 +6441,21 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const notice = useMemoryNotice(memoryIds, useNews); const changed = useNews((map) => latestMemoryChange(map, memoryIds)); const composerLabel = name ? t("Message {name}", { name }) : t("Message"); + const blank3 = useSessions((list) => list.byId[sessionId]?.blank === true); + const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on."); + (0, import_react10.useEffect)(() => { + if (bot?.remote && blank3) { + document.body.dataset.btRemoteChat = "empty"; + document.body.style.setProperty("--bt-remote-empty", JSON.stringify(remoteEmptyHint)); + } else { + delete document.body.dataset.btRemoteChat; + document.body.style.removeProperty("--bt-remote-empty"); + } + return () => { + delete document.body.dataset.btRemoteChat; + document.body.style.removeProperty("--bt-remote-empty"); + }; + }, [bot?.remote, blank3, remoteEmptyHint]); (0, import_react10.useEffect)(() => { document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); const input = document.querySelector("[data-composer-input]"); @@ -9042,7 +9078,7 @@ function BotSettingsForm({ bot, roster, actions, run }) { (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }), (0, import_react19.createElement)( Card, - { title: t("Manage") }, + { title: t("More") }, (0, import_react19.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, @@ -9403,7 +9439,7 @@ function NewChat({ mode, roster, actions }) { }); } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Agent"), run: () => run(async () => { + options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Bot"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -14496,6 +14532,19 @@ function apply(ctx) { } ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy"); ctx.effect(() => installBrandChrome(), "ds-bot: brand chrome"); + ctx.effect(() => { + const sessions = ctx.sessions.list; + const sync = () => { + if (typeof document === "undefined") return; + const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); + const label = sessionTabLabel(roster.getSnapshot(), row?.id); + if (!label) return; + const next = brandTitle(`${label} — FlyBot`); + if (document.title !== next) document.title = next; + }; + sync(); + return sessions.subscribe(sync); + }, "ds-bot: tab title"); const roster = createSource(EMPTY_ROSTER); migrateStorage(); const UNREAD_KEY = STORAGE.unread; diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 51ee13a38..8800651da 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -249,7 +249,7 @@ export function BotSettingsForm({ bot, roster, actions, run }) { h(SwitchRow, { label: t('Pin'), hint: t('Kept above the other chats.'), on: bot.pinned === true, onChange: pinned => run(actions.setFlags(bot.id, { pinned })) }), isMainBot ? null : h(SwitchRow, { label: t('Hide from sidebar'), hint: t('Search still finds it.'), on: bot.hidden === true, onChange: hidden => run(actions.setFlags(bot.id, { hidden })) })), h(FlyBotDetails, { bot, roster, actions }), - h(Card, { title: t('Manage') }, + h(Card, { title: t('More') }, h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), h('span', { className: 'bt-chevron' }, h(ChevronRightIcon))), diff --git a/core/src/client/brand.js b/core/src/client/brand.js index c6abc1045..5ee9ad5be 100644 --- a/core/src/client/brand.js +++ b/core/src/client/brand.js @@ -1,6 +1,8 @@ // FlyBot product chrome: the mark from docs/images/logo.svg, the tab title, and the favicon. // Paths stay here so tests can import them without React. +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' + export const FLYBOT_WING = 'M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z' export const FLYBOT_WING_LOW = 'M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z' export const FLYBOT_BODY = 'M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z' @@ -24,6 +26,22 @@ export function brandTitle(title) { return text } +export function sessionTabLabel(roster, sessionId) { + if (!roster || !sessionId) return '' + const bot = roster.byId?.[sessionId] + const room = roster.roomsById?.[sessionId] + if (bot?.remote) { + return remoteBotTitle({ + displayName: bot.name, + ownerName: bot.remote.homeName || bot.remote.owner, + handle: bot.remote.handle, + isChief: bot.remote.isChief, + }) + } + if (room) return room.displayName || (room.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room.name) || '' + return bot?.name || '' +} + export function installBrandChrome() { if (typeof document === 'undefined') return () => {} const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}` diff --git a/core/src/client/header.js b/core/src/client/header.js index dbbfe45bc..a4234644f 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -79,6 +79,21 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const changed = useNews(map => latestMemoryChange(map, memoryIds)) // The composer placeholder is not localizable per Session, so CSS reads it from here. const composerLabel = name ? t('Message {name}', { name }) : t('Message') + const blank = useSessions(list => list.byId[sessionId]?.blank === true) + const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on.") + useEffect(() => { + if (bot?.remote && blank) { + document.body.dataset.btRemoteChat = 'empty' + document.body.style.setProperty('--bt-remote-empty', JSON.stringify(remoteEmptyHint)) + } else { + delete document.body.dataset.btRemoteChat + document.body.style.removeProperty('--bt-remote-empty') + } + return () => { + delete document.body.dataset.btRemoteChat + document.body.style.removeProperty('--bt-remote-empty') + } + }, [bot?.remote, blank, remoteEmptyHint]) useEffect(() => { document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) const input = document.querySelector('[data-composer-input]') diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index b7cb09327..f16fa20dc 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -284,6 +284,7 @@ const ZH = { 'Kept above the other chats.': '排在其他对话上面。', 'Search still finds it.': '仍然可以搜索到。', Manage: '管理', + "Send a message. Your friend approves unless Auto-send is on.": '发送消息。除非已开启自动发送,否则好友需要批准。', // Group chat settings 'Leave it empty to name the group after its members.': '留空就按成员命名。', @@ -380,6 +381,7 @@ const ZH = { // stays in English. 'Add DSH Agent': '添加 DSH Agent', 'Add Agent': '添加 Agent', + 'Add Bot': '添加 Bot', 'Remove DSH Agent': '移除 DSH Agent', 'New session': '新会话', Archive: '归档', diff --git a/core/src/client/index.js b/core/src/client/index.js index 8ccd0a1ed..d7020eac6 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -6,7 +6,7 @@ import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } f import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' import { MENTION_ORIGIN, currentPart } from './text.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' -import { installBrandChrome } from './brand.js' +import { brandTitle, installBrandChrome, sessionTabLabel } from './brand.js' import { lookOf } from './mark.js' import { TeamSidebar, ConnectPlugins, YouAvatar, AccountLauncher, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' import { HeaderPill } from './header.js' @@ -50,6 +50,19 @@ export function apply(ctx) { } catch {} ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy') ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome') + ctx.effect(() => { + const sessions = ctx.sessions.list + const sync = () => { + if (typeof document === 'undefined') return + const row = Object.values(sessions.getSnapshot().byId ?? {}).find(session => (session?.retainedBy?.mainView ?? 0) > 0) + const label = sessionTabLabel(roster.getSnapshot(), row?.id) + if (!label) return + const next = brandTitle(`${label} — FlyBot`) + if (document.title !== next) document.title = next + } + sync() + return sessions.subscribe(sync) + }, 'ds-bot: tab title') const roster = createSource(EMPTY_ROSTER) migrateStorage() const UNREAD_KEY = STORAGE.unread diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 52319a3aa..816c27c16 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -132,7 +132,7 @@ export function NewChat({ mode, roster, actions }) { } // A team holds at most one Agent; the entry exists only while there is none. if ((roster.agents ?? []).length === 0) { - options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) + options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Bot'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } // The + menu is a short action list. Individual Bots (Chief and the rest) belong on diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 304b13693..71749ceb1 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -712,7 +712,8 @@ export const SIDEBAR_CSS = ` .bt-row-wrap{display:flex;align-items:center;width:100%;min-width:0} .bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0} -.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:.85;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -.bt-row-settings:hover,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} +.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} .bt-row-settings svg{width:15px;height:15px} ` diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 2417c0268..aadd5de52 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -148,6 +148,8 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row:has(.bt-row-remote) .bt-row-title,.bt-row-wrap:has(.bt-row-remote) .bt-row-title{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +.bt-row:has(.bt-row-remote) .bt-row-name,.bt-row-wrap:has(.bt-row-remote) .bt-row-name{align-items:flex-start} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -844,6 +846,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} the shell's new-session hero: no headline, no workspace or mode picker, and the composer stays docked at the bottom. */ [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 279bbe260..ae4c32b06 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -24,7 +24,7 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale - rt.uiLanguage = () => (uiLocale === undefined ? undefined : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) + rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) const ok = value => ({ ok: true, value }) const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) diff --git a/core/src/host/roster.js b/core/src/host/roster.js index c8b2bb19b..259500cbf 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -113,7 +113,7 @@ export function install(rt) { // The kickoff arrives as a user message, so it outranks the Bot's instructions // unless it gives way to them, and it names no tool those instructions may rule out. const kickoff = main - ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.` + ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'English'}.` : original ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.` : creator diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index efcbeb41a..593ec4616 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -44,7 +44,7 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => const menu = src.indexOf('menuOnly') const group = src.indexOf("t('Create group chat')") const actions = src.indexOf('for (const action of newChatActionRows') - const agent = src.indexOf("t('Add Agent')") + const agent = src.indexOf("t('Add Bot')") assert.ok(menu > 0, 'sidebar + starts as a short action menu') assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) assert.match(src, /listedChatBots/) @@ -72,7 +72,7 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => assert.equal(DICTIONARY.zh['Friend requests'], '好友请求') assert.equal(DICTIONARY.zh['Accept invite'], '接受邀请') assert.equal(DICTIONARY.zh['Copy invite link'], '复制邀请链接') - assert.equal(DICTIONARY.zh['Add Agent'], '添加 Agent') + assert.equal(DICTIONARY.zh['Add Bot'], '添加 Bot') assert.match(DICTIONARY.zh['Paste an invite link or code.'], /邀请/) assert.match(src, /if \(!menuOnly\)/) assert.match(src, /actions\.notify/) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 90725c33a..3cc7bb513 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -136,6 +136,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Header 'Message {name}': '给 {name} 发消息', +@@ -238,6 +238,7 @@ const ZH = { + 'Kept above the other chats.': '排在其他对话上面。', + 'Search still finds it.': '仍然可以搜索到。', + Manage: '管理', ++ "Send a message. Your friend approves unless Auto-send is on.": '发送消息。除非已开启自动发送,否则好友需要批准。', + + // Group chat settings + 'Leave it empty to name the group after its members.': '留空就按成员命名。', @@ -312,7 +312,7 @@ const ZH = { 'No group chats yet.': '还没有群聊。', 'A group chat lets several Bots work on one thing with you.': '在群聊里,几个 Bot 和你一起做一件事。', @@ -145,11 +153,12 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Settings pages': '设置页面', 'Models, providers, and the rest of DeepSeek Harness': '模型、服务商和 DeepSeek Harness 的其他设置', 'Harness settings': 'Harness 设置', -@@ -333,6 +333,7 @@ const ZH = { +@@ -333,6 +333,8 @@ const ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. 'Add DSH Agent': '添加 DSH Agent', + 'Add Agent': '添加 Agent', ++ 'Add Bot': '添加 Bot', 'Remove DSH Agent': '移除 DSH Agent', 'New session': '新会话', Archive: '归档', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 2cff00518..319bc3e20 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -98,7 +98,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const id = await createSession(cleanName, { dir }) try { state.bots[id] = { -@@ -83,9 +83,22 @@ export function install(rt) { +@@ -83,16 +83,29 @@ export function install(rt) { instructions: String(instructions).trim(), createdAt: Date.now(), createdBy: createdBy ?? null, @@ -122,6 +122,14 @@ diff --git a/src/host/roster.js b/src/host/roster.js const creator = rt.botOf(createdBy) const original = rt.botOf(copyOf) const language = rt.uiLanguage?.() + // The kickoff arrives as a user message, so it outranks the Bot's instructions + // unless it gives way to them, and it names no tool those instructions may rule out. + const kickoff = main +- ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.` ++ ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'English'}.` + : original + ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.` + : creator @@ -103,6 +103,11 @@ export function install(rt) { const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.` const text = !main && language ? `${opener}\nWrite in ${language}.` : opener diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 069a6e724..51fcf42f1 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -83,14 +83,16 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js 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 }) { +@@ -246,7 +246,8 @@ 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 })) }), isMainBot ? null : h(SwitchRow, { label: t('Hide from sidebar'), hint: t('Search still finds it.'), on: bot.hidden === true, onChange: hidden => run(actions.setFlags(bot.id, { hidden })) })), +- h(Card, { title: t('Manage') }, + h(FlyBotDetails, { bot, roster, actions }), - h(Card, { title: t('Manage') }, ++ h(Card, { title: t('More') }, h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), + h('span', { className: 'bt-chevron' }, h(ChevronRightIcon))), diff --git a/src/client/cells.js b/src/client/cells.js --- a/src/client/cells.js +++ b/src/client/cells.js @@ -448,7 +450,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' import { MENTION_ORIGIN, currentPart } from './text.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' -+import { installBrandChrome } from './brand.js' ++import { brandTitle, installBrandChrome, sessionTabLabel } from './brand.js' import { lookOf } from './mark.js' import { TeamSidebar, ConnectPlugins, YouAvatar, AccountLauncher, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' import { HeaderPill } from './header.js' @@ -470,7 +472,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { GROUP_SETTINGS_CSS } from './group-settings.js' import { FOLDER_CSS } from './folder-menu.js' import { USAGE_CSS } from './usage-page.js' -@@ -37,7 +37,16 @@ import { noticeApprovals } from './approval-notices.js' +@@ -37,7 +37,29 @@ import { noticeApprovals } from './approval-notices.js' export const inject = ['sessions', 'uiWorkspace', 'uiConversation', 'slots', 'theme', 'layout'] export function apply(ctx) { @@ -484,6 +486,19 @@ diff --git a/src/client/index.js b/src/client/index.js + } catch {} ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy') + ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome') ++ ctx.effect(() => { ++ const sessions = ctx.sessions.list ++ const sync = () => { ++ if (typeof document === 'undefined') return ++ const row = Object.values(sessions.getSnapshot().byId ?? {}).find(session => (session?.retainedBy?.mainView ?? 0) > 0) ++ const label = sessionTabLabel(roster.getSnapshot(), row?.id) ++ if (!label) return ++ const next = brandTitle(`${label} — FlyBot`) ++ if (document.title !== next) document.title = next ++ } ++ sync() ++ return sessions.subscribe(sync) ++ }, 'ds-bot: tab title') const roster = createSource(EMPTY_ROSTER) migrateStorage() const UNREAD_KEY = STORAGE.unread @@ -677,7 +692,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + // A team holds at most one Agent; the entry exists only while there is none. if ((roster.agents ?? []).length === 0) { - options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) -+ options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) ++ options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add Bot'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } - for (const bot of matches) { @@ -1102,15 +1117,16 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // With the whale holding the settings seat, the footer row is just the account menu -@@ -658,4 +658,10 @@ export const SIDEBAR_CSS = ` +@@ -658,4 +658,11 @@ export const SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} + +.bt-row-wrap{display:flex;align-items:center;width:100%;min-width:0} +.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0} -+.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:.85;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -+.bt-row-settings:hover,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} ++.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} ` diff --git a/src/client/styles.js b/src/client/styles.js @@ -1155,10 +1171,12 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} .bt-round:hover{background:var(--bt-hover)} .bt-round:active{transform:scale(.94)} -@@ -132,21 +132,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -132,21 +132,25 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-row:has(.bt-row-remote) .bt-row-title,.bt-row-wrap:has(.bt-row-remote) .bt-row-title{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} ++.bt-row:has(.bt-row-remote) .bt-row-name,.bt-row-wrap:has(.bt-row-remote) .bt-row-name{align-items:flex-start} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -1210,6 +1228,14 @@ diff --git a/src/client/styles.js b/src/client/styles.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} +@@ -820,6 +820,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + the shell's new-session hero: no headline, no workspace or mode picker, and the + composer stays docked at the bottom. */ + [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} ++body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} + [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} + [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} + [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} @@ -905,6 +905,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 5dc2ef08d..e8a12fde4 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -141,7 +141,15 @@ diff --git a/src/host/channel.js b/src/host/channel.js const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } -@@ -28,7 +28,8 @@ export function install(rt) { +@@ -21,14 +21,15 @@ export function install(rt) { + + // The DSH interface language of the last browser that asked for the team. + let uiLocale +- rt.uiLanguage = () => (uiLocale === undefined ? undefined : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) ++ rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) + + const ok = value => ({ ok: true, value }) + const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) // Models come from DSH, not state.json, so a provider added in Harness settings // changes the view without a new revision. const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.reasoning ? `${entry.ref}:${JSON.stringify(entry.reasoning)}` : entry.ref).join(',')}|${rt.cachedDefault() ?? ''}` diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e3dbd8c93..7c4ba0ba3 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -87,6 +87,14 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", +@@ -270,6 +270,7 @@ var ZH = { + "Kept above the other chats.": "排在其他对话上面。", + "Search still finds it.": "仍然可以搜索到。", + Manage: "管理", ++ "Send a message. Your friend approves unless Auto-send is on.": "发送消息。除非已开启自动发送,否则好友需要批准。", + // Group chat settings + "Leave it empty to name the group after its members.": "留空就按成员命名。", + "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", @@ -374,7 +374,7 @@ var ZH = { "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", @@ -96,11 +104,12 @@ diff --git a/client.js b/client.js "Settings pages": "设置页面", "Models, providers, and the rest of DeepSeek Harness": "模型、服务商和 DeepSeek Harness 的其他设置", "Harness settings": "Harness 设置", -@@ -394,6 +394,7 @@ var ZH = { +@@ -394,6 +394,8 @@ var ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", + "Add Agent": "添加 Agent", ++ "Add Bot": "添加 Bot", "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", @@ -1169,10 +1178,12 @@ diff --git a/client.js b/client.js .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} .bt-round:hover{background:var(--bt-hover)} .bt-round:active{transform:scale(.94)} -@@ -1950,21 +1950,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,21 +1950,25 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-row:has(.bt-row-remote) .bt-row-title,.bt-row-wrap:has(.bt-row-remote) .bt-row-title{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} ++.bt-row:has(.bt-row-remote) .bt-row-name,.bt-row-wrap:has(.bt-row-remote) .bt-row-name{align-items:flex-start} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -1224,6 +1235,14 @@ 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} +@@ -2637,6 +2637,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + the shell's new-session hero: no headline, no workspace or mode picker, and the + composer stays docked at the bottom. */ + [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} ++body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} + [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} + [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} + [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} @@ -2722,6 +2722,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} @@ -1232,18 +1251,10 @@ diff --git a/client.js b/client.js body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} -@@ -2847,234 +2847,116 @@ function installCompiledStyles(text, tag) { - 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); -- style.remove(); -- }; --} -- +@@ -2855,226 +2855,123 @@ function installCompiledStyles(text, tag) { + }; + } + -// src/client/mark.js -var import_react2 = require("react"); -var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1382,7 +1393,32 @@ diff --git a/client.js b/client.js - people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), - people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null - ); -- } ++// src/client/brand.js ++var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; ++var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; ++var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; ++function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { ++ return ``; ++} ++function brandTitle(title) { ++ const text = String(title ?? ""); ++ if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; ++ if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; ++ if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); ++ return text; ++} ++function sessionTabLabel(roster, sessionId) { ++ if (!roster || !sessionId) return ""; ++ const bot = roster.byId?.[sessionId]; ++ const room2 = roster.roomsById?.[sessionId]; ++ if (bot?.remote) { ++ return remoteBotTitle({ ++ displayName: bot.name, ++ ownerName: bot.remote.homeName || bot.remote.owner, ++ handle: bot.remote.handle, ++ isChief: bot.remote.isChief ++ }); + } - const seats = clusterSeats(Math.min(people.length, 4), size); - const overflow = people.length > 4 ? people.length - 3 : 0; - const gap = size / 14; @@ -1401,27 +1437,8 @@ diff --git a/client.js b/client.js - return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); -+ return () => { -+ observer.disconnect(); -+ document.removeEventListener("load", onLoad, true); -+ clearTimeout(timer); -+ style.remove(); -+ }; -+} -+ -+// src/client/brand.js -+var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; -+var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; -+var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; -+function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { -+ return ``; -+} -+function brandTitle(title) { -+ const text = String(title ?? ""); -+ if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; -+ if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; -+ if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); -+ return text; ++ if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; ++ return bot?.name || ""; +} +function installBrandChrome() { + if (typeof document === "undefined") return () => { @@ -3739,7 +3756,8 @@ diff --git a/client.js b/client.js "span", { className: "bt-connect-wrap" }, - (0, import_react8.createElement)( -- "button", ++ (0, import_react9.createElement)( + "button", - { - ref: button, - type: "button", @@ -3760,8 +3778,6 @@ diff --git a/client.js b/client.js - { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, - t("Plugin compatibility is not fully verified.") - ), document.body) : null -+ (0, import_react9.createElement)( -+ "button", + { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.selectPanel("plugins") }, + (0, import_react9.createElement)(PlugIcon), + t("Connect plugins") @@ -3916,15 +3932,16 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5491,24 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,25 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} + +.bt-row-wrap{display:flex;align-items:center;width:100%;min-width:0} +.bt-row-wrap>.bt-row{flex:1 1 auto;min-width:0} -+.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:.85;transition:background-color .12s ease,color .12s ease,opacity .12s ease} -+.bt-row-settings:hover,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-2px 8px -2px 2px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);cursor:pointer;opacity:0;pointer-events:none;transition:background-color .12s ease,color .12s ease,opacity .12s ease} ++.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} ++.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} `; @@ -3976,7 +3993,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,19 +5548,35 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,19 +5548,50 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -3991,6 +4008,21 @@ diff --git a/client.js b/client.js - }, [name, t("Message")]); - (0, import_react9.useEffect)(() => { + const composerLabel = name ? t("Message {name}", { name }) : t("Message"); ++ const blank3 = useSessions((list) => list.byId[sessionId]?.blank === true); ++ const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on."); ++ (0, import_react10.useEffect)(() => { ++ if (bot?.remote && blank3) { ++ document.body.dataset.btRemoteChat = "empty"; ++ document.body.style.setProperty("--bt-remote-empty", JSON.stringify(remoteEmptyHint)); ++ } else { ++ delete document.body.dataset.btRemoteChat; ++ document.body.style.removeProperty("--bt-remote-empty"); ++ } ++ return () => { ++ delete document.body.dataset.btRemoteChat; ++ document.body.style.removeProperty("--bt-remote-empty"); ++ }; ++ }, [bot?.remote, blank3, remoteEmptyHint]); + (0, import_react10.useEffect)(() => { + document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); + const input = document.querySelector("[data-composer-input]"); @@ -6224,8 +6256,9 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }), + (0, import_react19.createElement)( Card, - { title: t("Manage") }, +- { title: t("Manage") }, - (0, import_react18.createElement)( ++ { title: t("More") }, + (0, import_react19.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, @@ -6518,7 +6551,7 @@ diff --git a/client.js b/client.js + } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { -+ options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Agent"), run: () => run(async () => { ++ options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add Bot"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -10090,7 +10123,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13250,7 +13250,16 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since +@@ -13250,7 +13250,29 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { @@ -10104,6 +10137,19 @@ diff --git a/client.js b/client.js + } ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy"); + ctx.effect(() => installBrandChrome(), "ds-bot: brand chrome"); ++ ctx.effect(() => { ++ const sessions = ctx.sessions.list; ++ const sync = () => { ++ if (typeof document === "undefined") return; ++ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); ++ const label = sessionTabLabel(roster.getSnapshot(), row?.id); ++ if (!label) return; ++ const next = brandTitle(`${label} — FlyBot`); ++ if (document.title !== next) document.title = next; ++ }; ++ sync(); ++ return sessions.subscribe(sync); ++ }, "ds-bot: tab title"); const roster = createSource(EMPTY_ROSTER); migrateStorage(); const UNREAD_KEY = STORAGE.unread; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 4f7611857..010357951 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -363,7 +363,7 @@ diff --git a/src/client/header.js b/src/client/header.js // ------------------------------------------------------------------------- // Conversation header -@@ -68,16 +68,34 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -68,16 +68,49 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) @@ -378,9 +378,24 @@ diff --git a/src/client/header.js b/src/client/header.js const changed = useNews(map => latestMemoryChange(map, memoryIds)) // The composer placeholder is not localizable per Session, so CSS reads it from here. + const composerLabel = name ? t('Message {name}', { name }) : t('Message') ++ const blank = useSessions(list => list.byId[sessionId]?.blank === true) ++ const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on.") useEffect(() => { - document.body.style.setProperty('--bt-placeholder', JSON.stringify(name ? t('Message {name}', { name }) : t('Message'))) - }, [name, t('Message')]) ++ if (bot?.remote && blank) { ++ document.body.dataset.btRemoteChat = 'empty' ++ document.body.style.setProperty('--bt-remote-empty', JSON.stringify(remoteEmptyHint)) ++ } else { ++ delete document.body.dataset.btRemoteChat ++ document.body.style.removeProperty('--bt-remote-empty') ++ } ++ return () => { ++ delete document.body.dataset.btRemoteChat ++ document.body.style.removeProperty('--bt-remote-empty') ++ } ++ }, [bot?.remote, blank, remoteEmptyHint]) ++ useEffect(() => { + document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) + const input = document.querySelector('[data-composer-input]') + const priorLabel = input?.getAttribute('aria-label') ?? null diff --git a/patches/ds-bot/files/src/client/brand.js b/patches/ds-bot/files/src/client/brand.js index c6abc1045..5ee9ad5be 100644 --- a/patches/ds-bot/files/src/client/brand.js +++ b/patches/ds-bot/files/src/client/brand.js @@ -1,6 +1,8 @@ // FlyBot product chrome: the mark from docs/images/logo.svg, the tab title, and the favicon. // Paths stay here so tests can import them without React. +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' + export const FLYBOT_WING = 'M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z' export const FLYBOT_WING_LOW = 'M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z' export const FLYBOT_BODY = 'M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z' @@ -24,6 +26,22 @@ export function brandTitle(title) { return text } +export function sessionTabLabel(roster, sessionId) { + if (!roster || !sessionId) return '' + const bot = roster.byId?.[sessionId] + const room = roster.roomsById?.[sessionId] + if (bot?.remote) { + return remoteBotTitle({ + displayName: bot.name, + ownerName: bot.remote.homeName || bot.remote.owner, + handle: bot.remote.handle, + isChief: bot.remote.isChief, + }) + } + if (room) return room.displayName || (room.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room.name) || '' + return bot?.name || '' +} + export function installBrandChrome() { if (typeof document === 'undefined') return () => {} const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}` diff --git a/patches/ds-bot/files/test/new-chat.test.js b/patches/ds-bot/files/test/new-chat.test.js index efcbeb41a..593ec4616 100644 --- a/patches/ds-bot/files/test/new-chat.test.js +++ b/patches/ds-bot/files/test/new-chat.test.js @@ -44,7 +44,7 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => const menu = src.indexOf('menuOnly') const group = src.indexOf("t('Create group chat')") const actions = src.indexOf('for (const action of newChatActionRows') - const agent = src.indexOf("t('Add Agent')") + const agent = src.indexOf("t('Add Bot')") assert.ok(menu > 0, 'sidebar + starts as a short action menu') assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) assert.match(src, /listedChatBots/) @@ -72,7 +72,7 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => assert.equal(DICTIONARY.zh['Friend requests'], '好友请求') assert.equal(DICTIONARY.zh['Accept invite'], '接受邀请') assert.equal(DICTIONARY.zh['Copy invite link'], '复制邀请链接') - assert.equal(DICTIONARY.zh['Add Agent'], '添加 Agent') + assert.equal(DICTIONARY.zh['Add Bot'], '添加 Bot') assert.match(DICTIONARY.zh['Paste an invite link or code.'], /邀请/) assert.match(src, /if \(!menuOnly\)/) assert.match(src, /actions\.notify/) diff --git a/plugins/social/client.js b/plugins/social/client.js index d44dfa5ff..0bf781966 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -105,7 +105,10 @@ window.__ModuleLoader__.load({ .fs-pending-one{flex:1 1 auto;font-size:12px;line-height:16px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} .fs-draft{font-size:13px;line-height:18px;color:var(--bt-ink,#141414);white-space:pre-wrap;overflow-wrap:anywhere;max-height:96px;overflow:auto} .fs-digits{flex:1 1 auto;min-width:0;width:0;height:30px;padding:0 9px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:16px;letter-spacing:.24em;font-variant-numeric:tabular-nums;outline:none;text-align:center} +.fs-digits::placeholder{color:var(--bt-ink-3,rgba(20,20,20,.36));opacity:1;font-weight:400;letter-spacing:.24em} .fs-digits:focus{border-color:var(--bt-accent,#4d6bfe)} +.fs-sharing{display:flex;flex-direction:column;gap:10px} +.fs-sharing > .fs-label{padding:0 0 2px;text-transform:none;letter-spacing:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink,#141414)} .fs-face{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px;min-width:0} .fs-face-item{display:flex;flex-direction:column;gap:2px;min-width:0;width:100%;overflow:hidden} .fs-face-row{display:flex;align-items:center;gap:6px;min-width:0;width:100%;padding:2px 0;flex-wrap:nowrap;overflow:hidden} @@ -124,7 +127,7 @@ window.__ModuleLoader__.load({ .fs-face-head .fs-face-auto-wrap{flex:1 1 auto;min-width:0} .fs-face-head .fs-face-actions{flex:none;margin-left:auto} .fs-past{margin:2px 0 4px} -.fs-past-head{display:flex;align-items:center;gap:6px;width:100%;padding:4px 8px;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3,rgba(20,20,20,.36));font:inherit;font-size:11px;line-height:14px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;text-align:left;cursor:pointer} +.fs-past-head{display:flex;align-items:center;gap:6px;width:100%;padding:4px 8px;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3,rgba(20,20,20,.36));font:inherit;font-size:12px;line-height:16px;font-weight:500;text-align:left;cursor:pointer} .fs-past-head:hover{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-past-head .fs-chev[data-closed]{transform:rotate(-90deg)} .fs-face-auto-wrap{display:inline-flex;align-items:center;gap:2px;padding:0} @@ -496,6 +499,14 @@ window.__ModuleLoader__.load({ }) }, () => {}) }, []) + useEffect(() => { + if (!onClose || typeof document?.addEventListener !== 'function') return undefined + const onKey = (event) => { + if (event.key === 'Escape') { event.preventDefault(); onClose() } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) useEffect(() => { if (step !== 'waiting' || !(room?.faceRoomId || room?.roomId)) return undefined const faceId = room.faceRoomId @@ -636,7 +647,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'll wait together until someone enters the chat."), + h('div', { className: 'fs-hint' }, 'Pick your Bots and enter the same 6-digit number as everyone nearby.'), h('ul', { className: 'fs-tiles fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' }, bots.map(bot => h(FaceTile, { key: bot.id, @@ -648,7 +659,7 @@ window.__ModuleLoader__.load({ }))), bots.length === 0 ? h('div', { className: 'fs-hint' }, 'Loading your Bots…') : null, h('div', { className: 'fs-hint', 'data-flybot': 'face-join-room' }, - 'Available to this room only. Friends cannot see these Bots unless you invite them.'), + 'Room-only: friends do not see these Bots unless you invite them.'), h('div', { className: 'fs-line' }, h('input', { className: 'fs-digits', value: code, inputMode: 'numeric', autoComplete: 'off', maxLength: 6, @@ -1146,6 +1157,14 @@ window.__ModuleLoader__.load({ setJoiner(current => current || (bots.find(bot => bot.isMain) ?? bots[0])?.id || '') }, () => {}) }, []) + useEffect(() => { + if (!onDone || typeof document?.addEventListener !== 'function') return undefined + const onKey = (event) => { + if (event.key === 'Escape') { event.preventDefault(); onDone() } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onDone]) const run = async (label, fn) => { setBusy(true) setStatus(null) @@ -1205,13 +1224,12 @@ window.__ModuleLoader__.load({ h('form', { onSubmit: join, className: 'fs-card bt-social-join' }, h('div', { className: 'fs-hint' }, 'Got a link from a friend?'), h('div', { className: 'fs-line' }, - 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('input', { className: 'fs-input', value: code, placeholder: 'Paste invite link', '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 fs-join-as', 'data-flybot': 'join-as' }, + mine.length ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as', title: 'Your friend only sees this Bot unless you invite them to another.' }, 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), + mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))) : null), status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone, style: { padding: '2px 10px' } }, status.text) : null, incoming.length ? h('div', { className: 'bt-social-requests' }, h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))), @@ -1256,7 +1274,7 @@ window.__ModuleLoader__.load({ return Boolean(status.value?.connected && status.value.bots.some(bot => bot.id === botId)) } - const ACCESS_NOTE = 'Each grant is this Bot to one person. Accepting Invite to this Bot creates a grant. Revoke one person without touching the others. Default: nobody, including for the Chief.' + const ACCESS_NOTE = 'One person per grant. Revoke anyone without affecting the rest. Default: nobody, Chief included.' function ManageAccess({ bot }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') @@ -1282,7 +1300,7 @@ window.__ModuleLoader__.load({ } const grants = view?.grants ?? [] return h('div', { className: 'fs fs-card', 'data-flybot': 'manage-access' }, - h('div', { className: 'fs-name' }, 'Manage access'), + h('div', { className: 'fs-name' }, 'People with access'), h('div', { className: 'fs-hint fs-face-note' }, ACCESS_NOTE), grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Invite a friend to grant it.') : null, grants.map(grant => h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, @@ -1366,7 +1384,8 @@ window.__ModuleLoader__.load({ return h('div', { 'data-flybot': 'bot-social-details' }, h(FriendChatSettings, { bot, actions })) } - return h('div', { 'data-flybot': 'bot-social-details' }, + return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, + h('div', { className: 'fs-label' }, 'Sharing'), h(InviteToBot, { bot }), h(ManageAccess, { bot })) } @@ -1388,7 +1407,7 @@ window.__ModuleLoader__.load({ if (clip?.writeText) clip.writeText(text).then(() => setNote({ tone: 'ok', text: 'Copied.' }), () => setNote({ tone: 'error', text: 'Could not copy. Select the link and copy it by hand.' })) } return h('div', { className: 'fs fs-card', 'data-flybot': 'invite-to-bot' }, - h('div', { className: 'fs-hint' }, `Invite a friend to ${bot.name}. Accepting the invite grants them ${bot.name} only. You can revoke that grant later.`), + h('div', { className: 'fs-hint' }, `Invite grants ${bot.name} only. Revoke anytime in People with access.`), h('button', { type: 'button', className: 'fs-btn', disabled: busy, onClick: create }, invite ? 'Create another invite link' : 'Invite to this Bot'), invite ? h('div', { className: 'fs-line' }, h('input', { className: 'fs-input', readOnly: true, value: invite.link, 'aria-label': 'Invite link', onFocus: e => e.target.select() }), @@ -1453,13 +1472,6 @@ window.__ModuleLoader__.load({ hint: 'Paste an invite link or code.', run: ({ close } = {}) => { openAddFriend(); close?.() }, }), - slots().register('flybot.new-chat.action', { - id: 'social-accept-invite', - order: 17, - label: 'Accept invite', - hint: "Paste a friend’s invite link or code.", - run: ({ close } = {}) => { openAddFriend(); close?.() }, - }), slots().register('flybot.new-chat.action', { id: 'social-copy-invite', order: 18, diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 13ad4a393..90f4a19ed 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -221,8 +221,7 @@ export function personHomeOf(handle) { function titleHome(home) { const part = String(home ?? '').replace(/^@/, '').replace(/\.home$/i, '') - if (!part) return '' - return part.charAt(0).toUpperCase() + part.slice(1) + return part || '' } export function grantPersonView(grant) { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 094017a80..30e68308a 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -509,7 +509,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.new-chat.action#social-accept-invite', 'flybot.new-chat.action#social-copy-invite', 'flybot.new-chat.action#social-copy-handle', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.new-chat.action#social-copy-invite', 'flybot.new-chat.action#social-copy-handle', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x') @@ -1823,7 +1823,7 @@ test('social section has no + Add; Add friend and Face-to-face group live on the assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.room.overview' && entry.id === 'social-face')) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-face-group')) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-add-friend')) - assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-accept-invite')) + assert.equal(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-accept-invite'), false) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-invite')) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-handle')) assert.equal(closed.nodes.some(n => n.type === 'button' && n.children.includes('Copy')), false) @@ -1936,8 +1936,6 @@ test('sidebar + Add friend opens the join panel in Friends\' Bots; header stays assert.ok(after.some(n => n.props['data-flybot'] === 'add-friend')) assert.ok(after.some(n => n.type === 'button' && n.children.includes('Join'))) assert.equal(after.some(n => n.type === 'button' && n.children.includes('+ Add')), false) - const accept = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-accept-invite') - assert.equal(accept[1].label, 'Accept invite') const copy = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-invite') assert.equal(copy[1].label, 'Copy invite link') const handle = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-handle') @@ -2497,7 +2495,7 @@ test('Bot settings: Manage access lists grants and revokes one', async () => { const tree = walk(plugin.ManageAccess({ bot: { id: 'b', name: 'Writer' } })) assert.ok(tree.some(n => n.props['data-flybot'] === 'manage-access')) assert.ok(tree.some(n => n.props['data-flybot'] === 'access-grant')) - assert.ok(tree.some(n => typeof n.children?.[0] === 'string' && /Invite to this Bot/.test(n.children[0]))) + assert.ok(tree.some(n => typeof n.children?.[0] === 'string' && /People with access/.test(n.children[0]))) const revoke = tree.find(n => n.props['data-flybot'] === 'revoke-grant') assert.ok(revoke) await revoke.props.onClick() @@ -2548,7 +2546,7 @@ 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-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 ?? '')))) + assert.ok(picker.some(n => /everyone nearby/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') @@ -2566,7 +2564,7 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.equal(picker.some(n => n.props.className === 'fs-sub' && n.children.includes('Main Bot')), false) const hint = picker.find(n => n.props['data-flybot'] === 'face-join-room') assert.ok(hint) - assert.match(hint.children.join(''), /Available to this room only/) + assert.match(hint.children.join(''), /Room-only/) assert.equal(picker.some(n => /published/i.test(String(n.children ?? ''))), false) assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Chief' && n.props.checked === true)) assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Writer' && n.props.checked === false)) @@ -3345,7 +3343,7 @@ test('social/access labels an object-shaped list_bot_grants grantee, not @object const row = access.grants.find(g => g.home === '@bob.home' || g.grantee === '@bob.home' || /coder\.bob/.test(String(g.grantee))) assert.ok(row, `expected a Bob grant, got ${JSON.stringify(access.grants)}`) assert.equal(row.home, '@bob.home') - assert.equal(row.name, 'Bob (@bob.home)') + assert.equal(row.name, 'bob (@bob.home)') assert.doesNotMatch(String(row.name), /object/i) assert.doesNotMatch(String(row.grantee), /object/i) }) @@ -3611,7 +3609,7 @@ test('#136 / #138: grant labels use the person home, not a raw member handle', ( assert.equal(personHomeOf('@selfb1935.home'), '@selfb1935.home') const view = grantPersonView({ grantee: 'Chief@chief.selfb1935.home', name: 'Chief@chief.selfb1935.home' }) assert.equal(view.home, '@selfb1935.home') - assert.equal(view.label, 'Selfb1935 (@selfb1935.home)') + assert.equal(view.label, 'selfb1935 (@selfb1935.home)') const named = grantPersonView({ grantee: '@bob.home', name: 'Bob' }) assert.equal(named.label, 'Bob (@bob.home)') const normalized = normalizeGrant({ grantee: 'Chief@chief.bob.home', display_name: 'Chief@chief.bob.home' }) @@ -3917,7 +3915,7 @@ test('#136: + Copy my handle copies the home; Auto-send starts unchecked; Join-a 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) + assert.ok(add.find(n => n.props['data-flybot'] === 'join-as').props.title) }) test('#160: Remove this Bot closes the settings overlay', async () => { @@ -3963,7 +3961,8 @@ test('#138: join success copy names the person and the Bot', () => { const src = readFileSync(new URL('../client.js', import.meta.url), 'utf8') 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, /Your friend only sees this Bot unless you invite them to another/) + assert.doesNotMatch(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/) }) From d2512080c22cc099d4b534ac28ca87b8e6470f36 Mon Sep 17 00:00:00 2001 From: Birfy Date: Sat, 17 Oct 2026 18:27:11 +0800 Subject: [PATCH 2/3] docs: describe social UI polish round 3 (#173) --- docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 1 + docs/subsystems/social.md | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index ffcbfa8a4..064be03c0 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -72,6 +72,7 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send ### FlyBot product chrome - User-visible chrome says **FlyBot** (header mark + name, About, settings title, tab title, favicon, zh copy). The mark is the existing FlyBot logo (`docs/images/logo.svg`), inlined as `FlyBotMark` from `src/client/brand.js`. Update popups say **FlyBot core {version}**. The npm package remains `ds-bot`; plugin id, localStorage keys, and `console.warn('[ds-bot]')` stay upstream. +- The tab title follows the open chat (`sessionTabLabel` in `brand.js`: a friend's Bot reads `Name · owner`, a room uses its display name). Sidebar row gear controls are hover/focus-only, and Bot settings call the duplicate/delete card **More**. An empty chat with a friend's Bot shows a one-line hint (`--bt-remote-empty`, set by `HeaderPill`) that the friend approves unless Auto-send is on (#173). The agent entry on **+** reads **Add Bot**. ### One sidebar list diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 2c684b6eb..42cac21f4 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -68,6 +68,7 @@ A tracked request with a stable id (`task-`) and a small state machine, own ### Face-to-face participant look - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. +- `rt.uiLanguage()` defaults to `English` until a browser reports a locale, so the Chief kickoff greets in English instead of Chinese on a fresh home (#173). - A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. ### FlyBot display copy diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dd55f6478..6fe7869d7 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -90,6 +90,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. +- Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. The social strip still refreshes past rooms about every 5s. - Sidebar **+** holds Face-to-face group, Add friend, Accept invite, one-click Copy invite link, and Copy my handle. Friend requests is not a duplicate + row. Per-friend Auto-send is on that Bot's settings; face-room Auto-send is on the open room overview — both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`. From b353478d6b3fe4c83010abf0a4aa4d35dce3d19a Mon Sep 17 00:00:00 2001 From: Birfy Date: Sat, 17 Oct 2026 18:27:20 +0800 Subject: [PATCH 3/3] docs: drop Accept invite row from social doc (#173) --- docs/subsystems/social.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 6fe7869d7..1958af240 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, 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). +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**, **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 (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend / 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 @@ -51,7 +51,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Inline approvals and compact Friends' Bots (#114, #115) - Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, left-aligned like a bubble — not full chat width): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Always approve** / **Reject** on one row for the owner. **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or face-room `social/face-auto`) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The social strip "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is a single compact checkbox (tooltip only) on the open room's overview — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. -- Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms** with **Open** only. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend**, **Accept invite**, **Copy invite link**, **Copy my handle**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Accept invite open the join panel. Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). +- Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms** with **Open** only. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend**, **Copy invite link**, **Copy my handle**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend opens the join panel (handle, invite link, or code). Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). ### Face-to-face waiting grid, leave, and social + (#124, #125, #126)