Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/305-face-room-at-menu.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
113 changes: 108 additions & 5 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ var ZH = {
" and ": "、",
and: "和",
"{count} members": "{count} 位成员",
"{count} member": "{count} 位成员",
"1 person": "1 人",
"{count} people": "{count} 人",
"1 Bot": "1 个 Bot",
Expand Down Expand Up @@ -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" },
Expand All @@ -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"))
)
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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();
};
Expand All @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions core/src/client/at-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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).
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions core/src/client/face-at-filter.js
Original file line number Diff line number Diff line change
@@ -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-<source>-<index>` (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()
}
}
3 changes: 3 additions & 0 deletions core/src/client/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const ZH = {
' and ': '、',
and: '和',
'{count} members': '{count} 位成员',
'{count} member': '{count} 位成员',
'1 person': '1 人',
'{count} people': '{count} 人',
'1 Bot': '1 个 Bot',
Expand Down
Loading
Loading