From 69831dfe6c6cddeb231da5c67593ee95d3f4f730 Mon Sep 17 00:00:00 2001 From: Birfy Date: Mon, 19 Oct 2026 17:03:04 +0800 Subject: [PATCH] Face room @ menu: hide DSH's Sessions rows, keep the highlight off them DSH's built-in reference source cannot be dropped for one Session (#422), so in a face-to-face room FlyBot watches the Session's trigger controller: CSS hides the Session rows and their section title, the highlight is moved past them, and a menu with nothing else is dismissed so Enter sends. Everyone in a group counts the Bots it reaches, 1 member / N members. Fixes #305 --- changelog.d/305-face-room-at-menu.fixed.md | 1 + core/client.js | 113 ++- core/src/client/at-menu.js | 8 +- core/src/client/face-at-filter.js | 93 ++ core/src/client/header.js | 3 + core/src/client/i18n.js | 1 + core/src/client/index.js | 25 + core/src/client/palette.js | 2 +- core/test/at-menu.test.js | 13 + core/test/face-at-filter.test.js | 93 ++ docs/subsystems/core-client.md | 6 +- patches/ds-bot/0001-core-fixes.patch | 3 +- patches/ds-bot/0003-flybot-api-client.patch | 72 +- patches/ds-bot/0008-client-bundle.patch | 825 ++++++++++++------ patches/ds-bot/0012-room-membership.patch | 24 +- .../ds-bot/files/src/client/face-at-filter.js | 93 ++ .../ds-bot/files/test/face-at-filter.test.js | 93 ++ 17 files changed, 1167 insertions(+), 301 deletions(-) create mode 100644 changelog.d/305-face-room-at-menu.fixed.md create mode 100644 core/src/client/face-at-filter.js create mode 100644 core/test/face-at-filter.test.js create mode 100644 patches/ds-bot/files/src/client/face-at-filter.js create mode 100644 patches/ds-bot/files/test/face-at-filter.test.js diff --git a/changelog.d/305-face-room-at-menu.fixed.md b/changelog.d/305-face-room-at-menu.fixed.md new file mode 100644 index 000000000..2e351473c --- /dev/null +++ b/changelog.d/305-face-room-at-menu.fixed.md @@ -0,0 +1 @@ +A face-to-face room's `@` menu lists only the room's people and Bots: DSH's own Sessions rows and their section title no longer show there, arrow keys skip them and Enter never picks one; elsewhere a group's **Everyone** row counts the Bots it reaches, seated friends' Bots too, as `1 member` / `2 members` ([#305](https://github.com/ThinkFlowLab/FlyBot/issues/305)). diff --git a/core/client.js b/core/client.js index 62094beaa..792f9f50b 100644 --- a/core/client.js +++ b/core/client.js @@ -102,6 +102,7 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", + "{count} member": "{count} 位成员", "1 person": "1 人", "{count} people": "{count} 人", "1 Bot": "1 个 Bot", @@ -4690,7 +4691,7 @@ function ScopeEditor({ secret, roster, onSave }) { const [mode, setMode] = (0, import_react6.useState)(secret.scope === "all" ? "all" : "some"); const [picked, setPicked] = (0, import_react6.useState)(secret.scope === "all" ? [] : secret.scope); const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); - const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); + const same2 = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); return (0, import_react6.createElement)( "div", { className: "bt-secret-edit-block" }, @@ -4715,7 +4716,7 @@ function ScopeEditor({ secret, roster, onSave }) { (0, import_react6.createElement)( "div", { className: "bt-secret-row-actions" }, - (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) ) ); } @@ -7049,6 +7050,8 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe actions.beginVisit(sessionId); return () => actions.endVisit(sessionId); }, [sessionId]); + const face = room2?.origin === "face-to-face"; + (0, import_react10.useEffect)(() => face ? actions.filterFaceAtMenu?.(sessionId) : void 0, [sessionId, face]); (0, import_react10.useEffect)(() => { if (room2) document.body.dataset.btRoom = ""; else delete document.body.dataset.btRoom; @@ -7449,7 +7452,7 @@ function mentionRows(roster, sessionId, query = "") { const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` })); const everyone = t("Everyone"); if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { - rows.push({ room: here.room, text: `@${everyone} ` }); + rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` }); } if (face) return rows; const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean)); @@ -7461,6 +7464,7 @@ function mentionRows(roster, sessionId, query = "") { ]; } var isFaceRoom = (room2) => room2?.origin === "face-to-face"; +var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; var myOwnerName = (roster, live, bot) => { @@ -7518,7 +7522,7 @@ function atSources({ roster, sessions, launched, iconFor }) { ...row.remote ? { description: row.remote.handle } : {}, ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). - ...row.room ? { description: face ? t("Every Bot in this room") : t("{count} members", { count: row.room.members.length }) } : {}, + ...row.room ? { description: face ? t("Every Bot in this room") : memberCount(row.count) } : {}, icon: iconFor(row), section: t("Bots"), value: row.text @@ -7560,6 +7564,83 @@ function atSources({ roster, sessions, launched, iconFor }) { return [bots, chats]; } +// src/client/face-at-filter.js +var REFERENCE_SOURCE = "reference"; +var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; +var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session"; +var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index; +function planFaceMenu(state, previous = null) { + const hidden = []; + const ready = []; + let shown = 0; + let loading = false; + for (const group of state?.groups ?? []) { + const items = group.items ?? []; + if (group.status !== "ready" && items.length === 0) loading = true; + items.forEach((item, index) => { + const hide = isSessionRow(group, item); + if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section }); + else shown += 1; + if (group.status === "ready") ready.push({ source: group.source, index, hide }); + }); + } + const open = state?.open === true; + const blank3 = open && hidden.length > 0 && shown === 0 && !loading; + const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready"); + return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) }; +} +function steer(ready, highlight, previous) { + const at = ready.findIndex((row) => same(row, highlight)); + if (at < 0 || !ready[at].hide) return null; + const from = ready.findIndex((row) => same(row, previous)); + const n = ready.length; + const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1; + for (let step = 1; step < n; step += 1) { + const row = ready[((at + dir * step) % n + n) % n]; + if (!row.hide) return { source: row.source, index: row.index }; + } + return null; +} +function faceMenuCss({ hidden, blank: blank3 }) { + if (hidden.length === 0) return ""; + const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`); + const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`); + const css = [`${[...rows, ...titles].join(",")}{display:none!important}`]; + if (blank3) css.push("[data-trigger-menu]{display:none!important}"); + return css.join("\n"); +} +function watchFaceMenu(controller, doc = globalThis.document) { + if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => { + }; + const style = doc.createElement("style"); + style.setAttribute("data-bt-face-at", ""); + doc.head.appendChild(style); + let previous = null; + let stopped = false; + const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn); + const apply2 = () => { + if (stopped) return; + const state = controller.menu.getSnapshot(); + const plan = planFaceMenu(state, previous); + const css = faceMenuCss(plan); + if (style.textContent !== css) style.textContent = css; + if (plan.dismiss) later(() => { + if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss(); + }); + else if (plan.target) later(() => { + if (!stopped) controller.hover(plan.target.source, plan.target.index); + }); + else previous = state.open ? state.highlight : null; + }; + const unsubscribe = controller.menu.subscribe(apply2); + apply2(); + return () => { + stopped = true; + unsubscribe?.(); + style.remove(); + }; +} + // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); @@ -7769,7 +7850,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { key: room2.id, avatar: (0, import_react13.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), title: room2.name, - subtitle: t("{count} members", { count: room2.members.length }), + subtitle: t(room2.members.length === 1 ? "{count} member" : "{count} members", { count: room2.members.length }), badge: room2.hidden ? t("Hidden") : void 0, run: async () => { if (room2.hidden) await actions.setFlags(room2.id, { hidden: false }); @@ -16275,6 +16356,15 @@ function apply(ctx) { }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); let atMenu = null; + const faceMenus = /* @__PURE__ */ new Map(); + const watchFace = (sessionId) => { + try { + const controller = atMenu?.controllerOf(sessionId); + return controller ? watchFaceMenu(controller) : null; + } catch { + return null; + } + }; const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { @@ -16286,7 +16376,12 @@ function apply(ctx) { const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; + for (const [sessionId, stop] of faceMenus) faceMenus.set(sessionId, stop ?? watchFace(sessionId)); return () => { + for (const [sessionId, stop] of faceMenus) { + stop?.(); + faceMenus.set(sessionId, null); + } atMenu = null; for (const off of offs) off(); }; @@ -16295,6 +16390,14 @@ function apply(ctx) { return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; + actions.filterFaceAtMenu = (sessionId) => { + faceMenus.get(sessionId)?.(); + faceMenus.set(sessionId, watchFace(sessionId)); + return () => { + faceMenus.get(sessionId)?.(); + faceMenus.delete(sessionId); + }; + }; actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 0466a1ce2..bc655fe0a 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -2,7 +2,8 @@ // The composer's "@" in a Bot's chat or a group chat: a Bot group that inserts // "@Name", and the chats that "+ → Reference a chat" opens in the same menu. // Both are sources of the shell's trigger menu; the shell's own file and session -// rows stay beside them, because a source cannot be hidden for one Session. +// rows stay beside them, because a source cannot be hidden for one Session (a face +// room filters the session rows out of view: face-at-filter.js). import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' import { cleanPeerName, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' @@ -51,7 +52,7 @@ export function mentionRows(roster, sessionId, query = '') { .map(bot => ({ bot, text: `@${bot.name} ` })) const everyone = t('Everyone') if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some(word => word.toLowerCase().startsWith(needle))) { - rows.push({ room: here.room, text: `@${everyone} ` }) + rows.push({ room: here.room, count: bots.filter(bot => bot.kind !== 'human').length, text: `@${everyone} ` }) } // Friends' remote Bots are members of every chat, marked remote. Not of a face-to-face // room: only the Bots in that room can be reached there, each listed once above (#305). @@ -69,6 +70,7 @@ export function mentionRows(roster, sessionId, query = '') { } const isFaceRoom = room => room?.origin === 'face-to-face' +const memberCount = count => t(count === 1 ? '{count} member' : '{count} members', { count }) const normHandle = handle => String(handle ?? '').trim().replace(/^@+/, '').toLowerCase() // A friend's Bot chat in the roster: its remote record (the Bot itself in older rosters). export const remoteRecord = bot => (bot?.remote ? (bot.remote === true ? bot : bot.remote) : null) @@ -145,7 +147,7 @@ export function atSources({ roster, sessions, launched, iconFor }) { ...(row.remote ? { description: row.remote.handle } : {}), ...(row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}), // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). - ...(row.room ? { description: face ? t('Every Bot in this room') : t('{count} members', { count: row.room.members.length }) } : {}), + ...(row.room ? { description: face ? t('Every Bot in this room') : memberCount(row.count) } : {}), icon: iconFor(row), section: t('Bots'), value: row.text, diff --git a/core/src/client/face-at-filter.js b/core/src/client/face-at-filter.js new file mode 100644 index 000000000..611e810e8 --- /dev/null +++ b/core/src/client/face-at-filter.js @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------- +// A face-to-face room's "@" lists the people and the Bots in the room, nothing else (#305). +// The shell's own reference source adds its Sessions (and Subagents) rows to every "@" menu, +// and DSH gives a plugin no way to drop a built-in source for one Session (asked for in #422), +// so in a face room FlyBot filters them on its side: CSS takes those rows and their section +// title out of view, the highlight is moved off them (arrow keys skip them, Enter never picks +// one), and a menu left with nothing but them is dismissed, so Enter sends the line. +// Files stay: only Session rows are filtered. Replace with a DSH API once #422 has one. + +export const REFERENCE_SOURCE = 'reference' +// MenuView's option id, `dsh-slash-option--` (the listbox's aria-activedescendant). +const optionId = (source, index) => `dsh-slash-option-${source}-${index}` + +const isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === 'session' +const same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index + +// What to do with one menu snapshot: the rows to hide (each with whether its section title +// goes too), whether nothing visible is left, and where the highlight must go instead. +// `previous` is the last highlight seen, so a move that lands on a hidden row keeps going the +// same way. +export function planFaceMenu(state, previous = null) { + const hidden = [] + const ready = [] + let shown = 0 + let loading = false + for (const group of state?.groups ?? []) { + const items = group.items ?? [] + if (group.status !== 'ready' && items.length === 0) loading = true + items.forEach((item, index) => { + const hide = isSessionRow(group, item) + if (hide) hidden.push({ source: group.source, index, title: item.section !== undefined && item.section !== items[index - 1]?.section }) + else shown += 1 + if (group.status === 'ready') ready.push({ source: group.source, index, hide }) + }) + } + const open = state?.open === true + const blank = open && hidden.length > 0 && shown === 0 && !loading + const dismiss = blank && (state.groups ?? []).every(group => group.status === 'ready') + return { hidden, blank, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) } +} + +function steer(ready, highlight, previous) { + const at = ready.findIndex(row => same(row, highlight)) + if (at < 0 || !ready[at].hide) return null + const from = ready.findIndex(row => same(row, previous)) + const n = ready.length + const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1 + for (let step = 1; step < n; step += 1) { + const row = ready[(((at + dir * step) % n) + n) % n] + if (!row.hide) return { source: row.source, index: row.index } + } + return null +} + +export function faceMenuCss({ hidden, blank }) { + if (hidden.length === 0) return '' + const rows = hidden.map(row => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`) + const titles = hidden.filter(row => row.title) + .map(row => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`) + const css = [`${[...rows, ...titles].join(',')}{display:none!important}`] + if (blank) css.push('[data-trigger-menu]{display:none!important}') + return css.join('\n') +} + +// Keep one Session's "@" menu filtered while a face room shows; returns the stop function. +// `controller` is the shell's trigger controller for that Session (inputTriggers.sessionOf). +export function watchFaceMenu(controller, doc = globalThis.document) { + if (typeof controller?.menu?.subscribe !== 'function' || !doc?.head) return () => {} + const style = doc.createElement('style') + style.setAttribute('data-bt-face-at', '') + doc.head.appendChild(style) + let previous = null + let stopped = false + const later = typeof queueMicrotask === 'function' ? queueMicrotask : fn => Promise.resolve().then(fn) + const apply = () => { + if (stopped) return + const state = controller.menu.getSnapshot() + const plan = planFaceMenu(state, previous) + const css = faceMenuCss(plan) + if (style.textContent !== css) style.textContent = css + // The store notifies inside the shell's own reducer, so the correction runs right after it. + if (plan.dismiss) later(() => { if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss() }) + else if (plan.target) later(() => { if (!stopped) controller.hover(plan.target.source, plan.target.index) }) + else previous = state.open ? state.highlight : null + } + const unsubscribe = controller.menu.subscribe(apply) + apply() + return () => { + stopped = true + unsubscribe?.() + style.remove() + } +} diff --git a/core/src/client/header.js b/core/src/client/header.js index 1bce84d23..c44996c42 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -132,6 +132,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus actions.beginVisit(sessionId) return () => actions.endVisit(sessionId) }, [sessionId]) + // A face room's "@" lists the room's people and Bots, not the shell's Sessions (#305). + const face = room?.origin === 'face-to-face' + useEffect(() => (face ? actions.filterFaceAtMenu?.(sessionId) : undefined), [sessionId, face]) // Group chats have no voice mode, so the composer CSS needs to know the Session kind. useEffect(() => { if (room) document.body.dataset.btRoom = '' diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 1a5895d21..d02b644d1 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -65,6 +65,7 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', + '{count} member': '{count} 位成员', '1 person': '1 人', '{count} people': '{count} 人', '1 Bot': '1 个 Bot', diff --git a/core/src/client/index.js b/core/src/client/index.js index 2b3791cf7..aa1becba3 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -12,6 +12,7 @@ import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, FooterAccount, Br import { HeaderPill } from './header.js' import { ComposerIdentity, ComposerPlus, ComposerVoice, menuIcons } from './composer.js' import { CHATS_SOURCE, atSources } from './at-menu.js' +import { watchFaceMenu } from './face-at-filter.js' import { VOICE_BUNDLE } from './speech.js' import { sameVersion } from './dsh-offer.js' import { AnsweredCell, QuestionCard, QuestionDock } from './questions.js' @@ -747,6 +748,17 @@ export function apply(ctx) { // The "@" menu's Bot group and the chats "+" references. The composer's controller // for a Session is the shell's own, so the chats open at its caret. let atMenu = null + // Face rooms whose "@" menu leaves out the shell's Session rows (#305): Session id → stop, + // or null until the trigger service is up. + const faceMenus = new Map() + const watchFace = (sessionId) => { + try { + const controller = atMenu?.controllerOf(sessionId) + return controller ? watchFaceMenu(controller) : null + } catch { + return null + } + } const installAtMenu = () => { const fiber = ctx.inject(['inputTriggers'], (scope) => { scope.effect(() => { @@ -758,7 +770,12 @@ export function apply(ctx) { const launched = sessionId => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map(source => triggers.registerSource(source)) atMenu = { controllerOf } + for (const [sessionId, stop] of faceMenus) faceMenus.set(sessionId, stop ?? watchFace(sessionId)) return () => { + for (const [sessionId, stop] of faceMenus) { + stop?.() + faceMenus.set(sessionId, null) + } atMenu = null for (const off of offs) off() } @@ -767,6 +784,14 @@ export function apply(ctx) { return () => fiber.dispose() } actions.canReferenceChat = () => atMenu !== null + actions.filterFaceAtMenu = (sessionId) => { + faceMenus.get(sessionId)?.() + faceMenus.set(sessionId, watchFace(sessionId)) + return () => { + faceMenus.get(sessionId)?.() + faceMenus.delete(sessionId) + } + } actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId) if (controller === undefined || span === undefined) return diff --git a/core/src/client/palette.js b/core/src/client/palette.js index 5a009f974..0bd9d0d92 100644 --- a/core/src/client/palette.js +++ b/core/src/client/palette.js @@ -56,7 +56,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { run: async () => { if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); actions.openSession(bot.id) }, })), ...roster.rooms.filter(room => matchesName(room.name)).map(room => ({ - key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t('{count} members', { count: room.members.length }), + key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t(room.members.length === 1 ? '{count} member' : '{count} members', { count: room.members.length }), badge: room.hidden ? t('Hidden') : undefined, run: async () => { if (room.hidden) await actions.setFlags(room.id, { hidden: false }); actions.openSession(room.id) }, })), diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index 3ffe1e5a4..643ab76fb 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -173,3 +173,16 @@ test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, assert.equal(remoteBotTitle({ displayName: 'Coder', owner: 'Bob', homeName: 'Family Workspace' }), 'Coder (Bob)') assert.equal(remoteBotTitle({ displayName: 'Coder', homeName: 'Family Workspace' }), 'Coder (Family Workspace)') }) + +test('#305: "Everyone" in a group counts the Bots it reaches, seated friends\' Bots too, in the right number', async () => { + const room = (participants) => indexRoster({ + bots: [{ id: 'chief', name: 'Chief', sessionId: 'chief' }], + rooms: [{ id: 'g', name: 'Launch', members: ['chief'], participants }], + }) + const everyone = async (roster) => { + const [bots] = atSources({ roster: { getSnapshot: () => roster }, sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, launched: () => false, iconFor: () => 'bot' }) + return (await bots.candidates({ sessionId: 'g' }, { query: 'every' })).find(row => row.name === 'Everyone')?.description + } + assert.equal(await everyone(room([{ kind: 'human', id: 'ann', displayName: 'Ann' }])), '1 member') + assert.equal(await everyone(room([{ kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob' }])), '2 members') +}) diff --git a/core/test/face-at-filter.test.js b/core/test/face-at-filter.test.js new file mode 100644 index 000000000..5eedc5e7d --- /dev/null +++ b/core/test/face-at-filter.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { faceMenuCss, planFaceMenu, watchFaceMenu } from '../src/client/face-at-filter.js' + +const bots = (status = 'ready') => ({ source: 'ds-bot', status, items: [ + { name: 'Chief (you)', icon: 'bot', section: 'Bots', value: '@Chief ' }, + { name: 'Helper (SelfB)', icon: 'bot', section: 'Bots', value: '@Helper ' }, +] }) +const reference = (status = 'ready', files = 1) => ({ source: 'reference', status, showGroupTitle: false, items: [ + ...Array.from({ length: files }, (_, i) => ({ name: `notes-${i}.md`, icon: 'file', section: 'Files & folders' })), + { name: 'Child', icon: 'session', section: 'Subagents' }, + { name: 'Old chat', icon: 'session', section: 'Sessions' }, + { name: 'Older chat', icon: 'session', section: 'Sessions' }, +] }) +const menu = (groups, highlight) => ({ open: true, generation: 1, hit: {}, groups, highlight }) + +test('#305: a face room\'s "@" hides the shell\'s Session rows and their section titles, files and Bots stay', () => { + const plan = planFaceMenu(menu([bots(), reference()], { source: 'ds-bot', index: 0 })) + assert.deepEqual(plan.hidden, [ + { source: 'reference', index: 1, title: true }, + { source: 'reference', index: 2, title: true }, + { source: 'reference', index: 3, title: false }, + ]) + assert.equal(plan.blank, false) + assert.equal(plan.dismiss, false) + assert.equal(plan.target, null) + const css = faceMenuCss(plan) + for (const index of [1, 2, 3]) assert.match(css, new RegExp(`\\[data-trigger-menu\\] \\[id="dsh-slash-option-reference-${index}"\\]`)) + assert.match(css, /\[role="presentation"\]:has\(\+ \[id="dsh-slash-option-reference-1"\]\)/) + assert.match(css, /\[role="presentation"\]:has\(\+ \[id="dsh-slash-option-reference-2"\]\)/) + assert.doesNotMatch(css, /reference-0"|reference-3"\]\)|ds-bot-/) + assert.doesNotMatch(css, /^\[data-trigger-menu\]\{/m) + assert.equal(faceMenuCss(planFaceMenu(menu([bots()], null))), '') +}) + +test('#305: the highlight never rests on a hidden Session row, whichever way the keys move', () => { + const groups = [bots(), reference()] + // Down from the file row lands on the first Session row: it goes on, round to the first Bot. + assert.deepEqual(planFaceMenu(menu(groups, { source: 'reference', index: 1 }), { source: 'reference', index: 0 }).target, { source: 'ds-bot', index: 0 }) + // Up from the first Bot wraps to the last Session row: it goes on up, to the file row. + assert.deepEqual(planFaceMenu(menu(groups, { source: 'reference', index: 3 }), { source: 'ds-bot', index: 0 }).target, { source: 'reference', index: 0 }) + // Settled on a hidden row with nothing seen before: the next shown row. + assert.deepEqual(planFaceMenu(menu([bots(), reference('ready', 0)], { source: 'reference', index: 0 })).target, { source: 'ds-bot', index: 0 }) +}) + +test('#305: a face-room menu with nothing but Session rows is out of view and dismissed, so Enter sends', () => { + const only = menu([{ ...bots(), items: [] }, reference('ready', 0)], { source: 'reference', index: 0 }) + const plan = planFaceMenu(only) + assert.equal(plan.blank, true) + assert.equal(plan.dismiss, true) + assert.equal(plan.target, null) + assert.match(faceMenuCss(plan), /^\[data-trigger-menu\]\{display:none!important\}$/m) + // While the Bots still load, the menu stays (their skeleton shows) and nothing is dismissed. + const loading = planFaceMenu(menu([{ ...bots('pending'), items: [] }, reference('ready', 0)], { source: 'reference', index: 0 })) + assert.equal(loading.blank, false) + assert.equal(loading.dismiss, false) + assert.equal(planFaceMenu({ open: false, groups: [], highlight: null }).dismiss, false) +}) + +test('#305: the watcher styles the open menu, steers the highlight, dismisses, and cleans up', async () => { + let state = menu([bots(), reference()], { source: 'ds-bot', index: 0 }) + const listeners = new Set() + const calls = [] + const set = (next) => { state = next; for (const fn of [...listeners]) fn() } + const controller = { + menu: { getSnapshot: () => state, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn) } }, + hover: (source, index) => { calls.push(['hover', source, index]); set({ ...state, highlight: { source, index } }) }, + dismiss: () => { calls.push(['dismiss']); set({ open: false, generation: 1, hit: null, groups: [], highlight: null }) }, + } + const head = { children: [], appendChild(el) { this.children.push(el) } } + const doc = { + head, + createElement: () => { + const el = { textContent: '', attrs: {}, setAttribute(k, v) { this.attrs[k] = v }, remove() { head.children.splice(head.children.indexOf(el), 1) } } + return el + }, + } + const stop = watchFaceMenu(controller, doc) + assert.equal(head.children.length, 1) + assert.match(head.children[0].textContent, /reference-1/) + set({ ...state, highlight: { source: 'reference', index: 0 } }) + set({ ...state, highlight: { source: 'reference', index: 1 } }) + await Promise.resolve() + assert.deepEqual(calls, [['hover', 'ds-bot', 0]]) + set(menu([{ ...bots(), items: [] }, reference('ready', 0)], { source: 'reference', index: 0 })) + await Promise.resolve() + assert.deepEqual(calls.at(-1), ['dismiss']) + assert.equal(head.children[0].textContent, '') + stop() + assert.equal(head.children.length, 0) + assert.equal(listeners.size, 0) + assert.equal(typeof watchFaceMenu(null, doc), 'function') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0c9ece48f..f06e186ed 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -25,6 +25,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | --- | --- | | `core/src/client/` | Client sources → build to `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | +| `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). Tests: `core/test/schedules-client.test.js`. | @@ -113,9 +114,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). - A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `face-seats.js`, re-exported by `group-settings.js`, used by the overview, group settings and the bubble's @ chips): one row per live Bot, ` · `, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229). -- **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). The chat header, tab title, composer placeholder, row tooltip, approval overlay (`flybot-slots.js` `recipientName`), the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row) and in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` (`Chief (Bob)`, my own Bot `Chief (you)`) all use it. `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are not filtered yet (DSH API request: #422). +- **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). The chat header, tab title, composer placeholder, row tooltip, approval overlay (`flybot-slots.js` `recipientName`), the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row) and in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` (`Chief (Bob)`, my own Bot `Chief (you)`) all use it. `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). -- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`SelfA · Chief`, `SelfB (you) · Chief`), plus **Everyone** (`Every Bot in this room`; the room's local member list holds only this home's Bots, so it shows no count). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). +- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (you)`), plus **Everyone** (`Every Bot in this room`; the room's local member list holds only this home's Bots, so it shows no count). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). +- A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `HeaderPill` (`header.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `