From c2f3c3902f69c140bdd29acbae8c91802d916318 Mon Sep 17 00:00:00 2001 From: bjfwhite Date: Fri, 23 Oct 2026 15:26:11 +0800 Subject: [PATCH] Move the header effects off the corner seat that is already taken (#803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up that moved the pill's page effects out of the pill put them in `conversation.session.header.corner`. That seat cannot take them: it is `single`, and `ui-sidebar-right` already registers its right-sidebar button there with no `priority` (0). `SlotCore.register` throws on a second registration in the same cell — single slot "conversation.session.header.corner" already has a registration at priority 0 — register at a different priority to shadow it — and `SlotRegistry.inject` rethrows it out of the caller's effect, so FlyBot's whole client plugin fails from `apply` and the pill never mounts. `id` and `order` do not help: they are list-kind fields, and on a single seat the only ranking field is `priority`. Pushing it aside with `priority: -10` would hide the shell's own button, which is the failure this issue is about. The effects no longer need a seat at all. `installHeaderEffects` (`core/src/client/header-effects.js`) is installed once from `apply` and follows the main-view Session through the sessions list and the roster. - `core/src/client/header.js`: drops `HeaderSeats`; `--bt-first-sep` stays in `HeaderPill`, since it needs a turn and so always has a header to be drawn in. - `core/src/client/index.js`: installs the effects, registers nothing at `.corner`. - `core/src/client/header-effects.js`, `core/test/header-effects.test.js`: new; each effect keeps its own dependency key, because the sessions list answers before the roster and `blank` flips on the same Session when the first message arrives, while `beginVisit` must stay keyed by the Session alone. - `core/test/header-slot.test.js`: pins that nothing registers at `.corner`, with the reason, and that no page effect runs in the pill. - `docs/subsystems/core-client.md`, changelog: say the seat is taken. Fixes #803 --- .../803-subagent-catalog-visible.fixed.md | 2 +- core/client.js | 188 ++++++----- core/src/client/header-effects.js | 95 ++++++ core/src/client/header.js | 67 +--- core/src/client/index.js | 17 +- core/test/header-effects.test.js | 83 +++++ core/test/header-slot.test.js | 27 +- core/test/looks.test.js | 4 +- docs/subsystems/core-client.md | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 35 +- patches/ds-bot/0008-client-bundle.patch | 305 ++++++++++-------- patches/ds-bot/0012-room-membership.patch | 127 ++------ .../ds-bot/files/src/client/header-effects.js | 95 ++++++ .../ds-bot/files/test/header-effects.test.js | 83 +++++ patches/ds-bot/files/test/header-slot.test.js | 27 +- 15 files changed, 740 insertions(+), 419 deletions(-) create mode 100644 core/src/client/header-effects.js create mode 100644 core/test/header-effects.test.js create mode 100644 patches/ds-bot/files/src/client/header-effects.js create mode 100644 patches/ds-bot/files/test/header-effects.test.js diff --git a/changelog.d/803-subagent-catalog-visible.fixed.md b/changelog.d/803-subagent-catalog-visible.fixed.md index b6c0f657..cd2ef638 100644 --- a/changelog.d/803-subagent-catalog-visible.fixed.md +++ b/changelog.d/803-subagent-catalog-visible.fixed.md @@ -1 +1 @@ -The Session header shows the subagent catalog again ([#803](https://github.com/ThinkFlowLab/FlyBot/issues/803)). FlyBot claimed the shell's whole Session header as a single slot, so the child seats that header renders — the lineage title and the subagent catalog button among them — were never drawn and a started subagent had no way in from the header. The Bot pill now sits in the header's utilities list beside those seats instead of replacing the header, and the floating header style FlyBot drew over the Session header is gone with it (that change is visible in every chat). Because the shell draws the utilities — and the pill with them — only while its chrome shows, everything the pill does to the page (the friend-Bot empty-chat hint, the visit ledger, the composer placeholder, the room and face flags, the opening separator) now rides `conversation.session.header.corner`, which the shell draws in every state, so a Session without a first message keeps all of it. +The Session header shows the subagent catalog again ([#803](https://github.com/ThinkFlowLab/FlyBot/issues/803)). FlyBot claimed the shell's whole Session header as a single slot, so the child seats that header renders — the lineage title and the subagent catalog button among them — were never drawn and a started subagent had no way in from the header. The Bot pill now sits in the header's utilities list beside those seats instead of replacing the header, and the floating header style FlyBot drew over the Session header is gone with it (that change is visible in every chat). Because the shell draws the utilities — and the pill with them — only while its chrome shows, everything the pill does to the page (the friend-Bot empty-chat hint, the visit ledger, the composer placeholder, the room and face flags) is now installed once from the client plugin and follows the Session being looked at, so a Session without a first message keeps all of it. Nothing is claimed for it: `conversation.session.header.corner`, the other seat the shell draws in every state, already belongs to the shell's own right-sidebar button, and a second registration there fails the plugin. diff --git a/core/client.js b/core/client.js index 88d6a0db..929329c4 100644 --- a/core/client.js +++ b/core/client.js @@ -8121,38 +8121,6 @@ var SIDEBAR_CSS = ` .bt-row-settings svg{width:15px;height:15px} `; -// src/client/composer-label.js -var ATTRIBUTES = ["aria-label", "data-placeholder"]; -function holdComposerLabel(doc, label) { - doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); - const shell = /* @__PURE__ */ new Map(); - const apply2 = () => { - for (const el of doc.querySelectorAll("[data-composer-input]")) { - for (const name of ATTRIBUTES) { - const current = el.getAttribute(name); - if (current === label) continue; - if (!shell.has(el)) shell.set(el, {}); - shell.get(el)[name] = current; - el.setAttribute(name, label); - } - } - }; - apply2(); - const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; - const watcher = typeof Observer === "function" ? new Observer(apply2) : null; - watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); - return () => { - watcher?.disconnect(); - for (const [el, values] of shell) { - if (!el.isConnected) continue; - for (const [name, value] of Object.entries(values)) { - if (value == null) el.removeAttribute(name); - else el.setAttribute(name, value); - } - } - }; -} - // src/client/header.js var import_react14 = require("react"); var import_react_dom4 = require("react-dom"); @@ -8184,8 +8152,9 @@ function MemoryNotice({ shown }) { (0, import_react14.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react14.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react14.createElement)(MemoryIcon)), label) ); } -function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnClock, actions }) { +function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { const roster = useRoster((value) => value); + const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); const firstTurns = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; @@ -8201,52 +8170,6 @@ function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnClock, a else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); }, [opening]); - const bot = roster.byId[sessionId]; - const room2 = roster.roomsById[sessionId]; - const blank3 = useSessions((list) => list.byId[sessionId]?.blank === true); - const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; - const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on."); - (0, import_react14.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_react14.useEffect)(() => { - if (!bot?.remote) return void 0; - document.body.dataset.btFriendChat = ""; - return () => { - delete document.body.dataset.btFriendChat; - }; - }, [bot?.remote]); - const composerLabel = name ? t("Message {name}", { name }) : t("Message"); - (0, import_react14.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); - (0, import_react14.useEffect)(() => { - actions.beginVisit(sessionId); - return () => actions.endVisit(sessionId); - }, [sessionId]); - const face = room2?.origin === "face-to-face"; - (0, import_react14.useEffect)(() => face ? actions.filterFaceAtMenu?.(sessionId) : void 0, [sessionId, face]); - (0, import_react14.useEffect)(() => { - if (room2) document.body.dataset.btRoom = ""; - else delete document.body.dataset.btRoom; - return () => { - delete document.body.dataset.btRoom; - }; - }, [Boolean(room2)]); - return null; -} -function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { - const roster = useRoster((value) => value); - const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); - const hiddenTurns = roster.exchangeTurns?.[sessionId]; const running = useSessionStatus((map) => map.get(sessionId)?.running === true); const owner = roster.byId[sessionId]?.id ?? sessionId; const doing = useActivity((map) => map[owner]); @@ -8409,6 +8332,104 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl ); } +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { + doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); + const shell = /* @__PURE__ */ new Map(); + const apply2 = () => { + for (const el of doc.querySelectorAll("[data-composer-input]")) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name); + if (current === label) continue; + if (!shell.has(el)) shell.set(el, {}); + shell.get(el)[name] = current; + el.setAttribute(name, label); + } + } + }; + apply2(); + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; + const watcher = typeof Observer === "function" ? new Observer(apply2) : null; + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); + return () => { + watcher?.disconnect(); + for (const [el, values] of shell) { + if (!el.isConnected) continue; + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } + }; +} + +// src/client/header-effects.js +function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { + if (!doc) return () => { + }; + const slots = /* @__PURE__ */ new Map(); + const run = (id, dep, setup) => { + const held = slots.get(id); + if (held !== void 0 && held.dep === dep) return; + held?.off(); + slots.set(id, { dep, off: setup() ?? (() => { + }) }); + }; + const apply2 = () => { + const list = sessions.getSnapshot(); + const state = roster.getSnapshot(); + const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); + const sessionId = row?.id; + const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; + const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; + const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; + run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { + if (!bot?.remote) return void 0; + doc.body.dataset.btFriendChat = ""; + return () => { + delete doc.body.dataset.btFriendChat; + }; + }); + run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { + if (!bot?.remote || row?.blank !== true) return void 0; + doc.body.dataset.btRemoteChat = "empty"; + doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); + return () => { + delete doc.body.dataset.btRemoteChat; + doc.body.style.removeProperty("--bt-remote-empty"); + }; + }); + run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); + run("visit", sessionId, () => { + if (sessionId === void 0) return void 0; + actions.beginVisit(sessionId); + return () => actions.endVisit(sessionId); + }); + run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { + if (room2?.origin !== "face-to-face") return void 0; + const stop = actions.filterFaceAtMenu?.(sessionId); + return typeof stop === "function" ? stop : void 0; + }); + run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { + if (room2 === void 0) return void 0; + doc.body.dataset.btRoom = ""; + return () => { + delete doc.body.dataset.btRoom; + }; + }); + }; + apply2(); + const offSessions = sessions.subscribe(apply2); + const offRoster = roster.subscribe(apply2); + return () => { + offSessions?.(); + offRoster?.(); + for (const held of slots.values()) held.off(); + slots.clear(); + }; +} + // src/client/composer.js var import_react15 = require("react"); var import_react_dom5 = require("react-dom"); @@ -17776,6 +17797,12 @@ function apply(ctx) { const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); const face = () => ({ actions, hooks: { roster, ui: ui2, activity, work, memoryNews, registry: registry3, turnClock, live, cards, browserPages, browserTabs, surface, update } }); + ctx.effect(() => installHeaderEffects({ + sessions: ctx.sessions.list, + roster, + actions, + doc: typeof document === "undefined" ? void 0 : document + }), "ds-bot: session header effects"); ctx.effect(() => noticeApprovals({ work, roster, @@ -17891,9 +17918,6 @@ function apply(ctx) { // (ui-subagent) and the lineage title. FlyBot's pill is appended to the // utilities list beside them instead of replacing the whole header. shadow("conversation.session.header.utilities", { id: "bot-pill", order: 100, inject: face }, HeaderPill), - // The shell draws .utilities only while its chrome shows (a blank Session hides it), so what the - // pill does to the page rides .corner, which the shell draws in every state (#803). - shadow("conversation.session.header.corner", { id: "bot-header-seats", order: 100, inject: face }, HeaderSeats), shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), diff --git a/core/src/client/header-effects.js b/core/src/client/header-effects.js new file mode 100644 index 00000000..b27591f5 --- /dev/null +++ b/core/src/client/header-effects.js @@ -0,0 +1,95 @@ +// The Session's page effects (#803) live here rather than in header.js so they carry no +// React: they are installed once from `apply` and only follow two plain sources. +import { t } from './i18n.js' +import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { holdComposerLabel } from './composer-label.js' + +// The pill's effects belong to the *Session being looked at*, not to the pill being +// painted. They used to sit in HeaderPill's own effects, which tied them to wherever +// the pill happened to render: when the pill moved into a seat the shell hides on a +// blank Session, a friend's empty chat lost its hint, the local stats footer stopped +// hiding in a friend's chat (#419), a new group chat lost its voice styling, the visit +// ledger stopped recording, the composer label went back to the shell's copy and a +// face room's "@" listed the wrong people (#305). Installing them once, over the main +// view's Session, keeps every one of them independent of the pill's seat. +/** + * Keep the main-view Session's body flags, visit ledger, composer label and face "@" + * filter in step with the Session the user is looking at, for as long as FlyBot runs. + * @param root0 - the sessions list, the roster, their live sources and the client actions. + * @returns teardown for the subscriptions and the last Session's effects. + */ +export function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { + if (!doc) return () => {} + // One slot per effect, keyed by that effect's own dependencies, the way a React + // `useEffect` dependency list would be. A single "did the Session change" guard is + // not enough: the roster answers after the sessions list does, so on the first pass a + // friend's chat has no `bot` yet and its flags would never be set, and `blank` flips + // on the same Session when the first message arrives. Restarting everything on either + // would also record a second visit for a Session that had only just been opened. + const slots = new Map() + const run = (id, dep, setup) => { + const held = slots.get(id) + if (held !== undefined && held.dep === dep) return + held?.off() + slots.set(id, { dep, off: setup() ?? (() => {}) }) + } + const apply = () => { + const list = sessions.getSnapshot() + const state = roster.getSnapshot() + const row = Object.values(list?.byId ?? {}).find(entry => (entry?.retainedBy?.mainView ?? 0) > 0) + const sessionId = row?.id + const bot = sessionId === undefined ? undefined : state?.byId?.[sessionId] + const room = sessionId === undefined ? undefined : state?.roomsById?.[sessionId] + const name = bot?.remote + ? friendBotTitle(bot) + : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') + // A friend's Bot runs on its owner's home, so the local run stats are relay turns + // (always 0): CSS hides the shell's stats footer in that chat (#419). + run('friend-chat', `${sessionId}|${bot?.remote === true}`, () => { + if (!bot?.remote) return undefined + doc.body.dataset.btFriendChat = '' + return () => { delete doc.body.dataset.btFriendChat } + }) + // The hint is for an empty chat, which is exactly the state whose header seat the + // shell hides (#173). + run('remote-empty', `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { + if (!bot?.remote || row?.blank !== true) return undefined + doc.body.dataset.btRemoteChat = 'empty' + doc.body.style.setProperty('--bt-remote-empty', JSON.stringify(t('Send a message. Your friend approves unless Auto-send is on.'))) + return () => { + delete doc.body.dataset.btRemoteChat + doc.body.style.removeProperty('--bt-remote-empty') + } + }) + // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). + run('composer-label', `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t('Message {name}', { name }) : t('Message'))) + // Keyed by the Session alone: a roster answer that only names this Bot must not + // close and reopen the visit, which would lose what the visit had already recorded. + run('visit', sessionId, () => { + if (sessionId === undefined) return undefined + actions.beginVisit(sessionId) + return () => actions.endVisit(sessionId) + }) + // A face room's "@" lists the room's people and Bots, not the shell's Sessions (#305). + run('face-at-menu', `${sessionId}|${room?.origin === 'face-to-face'}`, () => { + if (room?.origin !== 'face-to-face') return undefined + const stop = actions.filterFaceAtMenu?.(sessionId) + return typeof stop === 'function' ? stop : undefined + }) + // Group chats have no voice mode, so the composer CSS needs to know the Session kind. + run('room-flag', `${sessionId}|${room !== undefined}`, () => { + if (room === undefined) return undefined + doc.body.dataset.btRoom = '' + return () => { delete doc.body.dataset.btRoom } + }) + } + apply() + const offSessions = sessions.subscribe(apply) + const offRoster = roster.subscribe(apply) + return () => { + offSessions?.() + offRoster?.() + for (const held of slots.values()) held.off() + slots.clear() + } +} diff --git a/core/src/client/header.js b/core/src/client/header.js index 8b3b7c2b..f02c60e9 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -3,11 +3,10 @@ import { createPortal } from 'react-dom' import { currentPart, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' -import { ActivityIndicator } from './activity-row.js' import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' +import { ActivityIndicator } from './activity-row.js' import { t } from './i18n.js' import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' -import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header @@ -44,21 +43,18 @@ function MemoryNotice({ shown }) { h('span', { className: 'bt-pill-mem-in', ref: inner }, h('span', { className: 'bt-pill-mem-ico' }, h(MemoryIcon)), label)) } -// Everything the header pill does to the page — the empty-chat hint, the visit ledger, the composer -// placeholder, the room and face flags — lives here rather than in the pill. The shell renders -// `conversation.session.header.utilities`, and the pill with it, only while its chrome shows -// (`!hideChrome`, the shell's `blank`), so a Session without a first message would drop every one of -// those effects; `conversation.session.header.corner` is rendered in every state, so they ride there (#803). -export function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnClock, actions }) { +export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { const roster = useRoster(value => value) + const title = useSessions(list => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? '') const firstTurns = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline return Array.isArray(outline) ? outline.slice(0, 8).map(entry => entry.turn).join(',') : '' }) const hiddenTurns = roster.exchangeTurns?.[sessionId] const visit = useUi(state => state.visits?.[sessionId]) - // The opening separator has no turn before it, so the transcript column draws it, - // unless the opening is unread: then the first trigger draws it under the divider. + // The opening separator is the one page effect that stays with the pill: it needs a turn, so + // the shell always draws the header for it, and it reads `useTurnClock`, which the installer + // in header-effects.js deliberately does not carry. const opening = useTurnClock((state) => { const first = openingTurn(state[sessionId], firstTurns, hiddenTurns) return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : '' @@ -68,57 +64,6 @@ export function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnC else document.body.style.removeProperty('--bt-first-sep') return () => document.body.style.removeProperty('--bt-first-sep') }, [opening]) - const bot = roster.byId[sessionId] - const room = roster.roomsById[sessionId] - const blank = useSessions(list => list.byId[sessionId]?.blank === true) - const name = bot?.remote - ? friendBotTitle(bot) - : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') - 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]) - // A friend's Bot runs on its owner's home: the local run stats (turns, steps, tok/s, cache) - // are relay turns and always 0 here, so CSS hides the shell's stats footer in that chat (#419). - useEffect(() => { - if (!bot?.remote) return undefined - document.body.dataset.btFriendChat = '' - return () => { delete document.body.dataset.btFriendChat } - }, [bot?.remote]) - // The composer placeholder is not localizable per Session, so CSS reads it from here. - const composerLabel = name ? t('Message {name}', { name }) : t('Message') - // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). - useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) - useEffect(() => { - 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 = '' - else delete document.body.dataset.btRoom - return () => { delete document.body.dataset.btRoom } - }, [Boolean(room)]) - return null -} - -export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { - const roster = useRoster(value => value) - const title = useSessions(list => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? '') - const hiddenTurns = roster.exchangeTurns?.[sessionId] const running = useSessionStatus(map => map.get(sessionId)?.running === true) const owner = roster.byId[sessionId]?.id ?? sessionId const doing = useActivity(map => map[owner]) diff --git a/core/src/client/index.js b/core/src/client/index.js index 5857b118..1516d5a7 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -9,7 +9,8 @@ import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } fro import { installBrandChrome, installTabTitle } from './brand.js' import { lookOf } from './mark.js' import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, ModelsSettingsStep, MODELS_STEP_ID, MODELS_STEP_ORDER, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' -import { HeaderPill, HeaderSeats } from './header.js' +import { HeaderPill } from './header.js' +import { installHeaderEffects } from './header-effects.js' import { ComposerIdentity, ComposerPlus, ComposerVoice, menuIcons } from './composer.js' import { CHATS_SOURCE, atSources } from './at-menu.js' import { watchFaceMenu } from './face-at-filter.js' @@ -757,6 +758,17 @@ export function apply(ctx) { const browserPages = browsers?.pages ?? createSource({}) const browserTabs = browsers?.tabs ?? createSource({}) const face = () => ({ actions, hooks: { roster, ui, activity, work, memoryNews, registry, turnClock, live, cards, browserPages, browserTabs, surface, update } }) + // What the pill does to the page follows the Session being looked at, not the seat the + // pill is painted in: the shell hides `.utilities` on a blank Session, so a Side effect + // parked in the pill stops there. Nothing is claimed for them either — `.corner`, the one + // header seat the shell draws in every state, already belongs to ui-sidebar-right's own + // button (a `single` slot registers at one priority per cell) (#803). + ctx.effect(() => installHeaderEffects({ + sessions: ctx.sessions.list, + roster, + actions, + doc: typeof document === 'undefined' ? undefined : document, + }), 'ds-bot: session header effects') ctx.effect(() => noticeApprovals({ work, roster, @@ -883,9 +895,6 @@ export function apply(ctx) { // (ui-subagent) and the lineage title. FlyBot's pill is appended to the // utilities list beside them instead of replacing the whole header. shadow('conversation.session.header.utilities', { id: 'bot-pill', order: 100, inject: face }, HeaderPill), - // The shell draws .utilities only while its chrome shows (a blank Session hides it), so what the - // pill does to the page rides .corner, which the shell draws in every state (#803). - shadow('conversation.session.header.corner', { id: 'bot-header-seats', order: 100, inject: face }, HeaderSeats), shadow('conversation.chat.node', { key: 'assistant-step', priority: -10, locale: 'chat', inject: assistantFace }, AssistantCell), shadow('conversation.chat.node', { key: 'turn-trigger', priority: -10, locale: 'chat', inject: face }, TriggerCell), shadow('conversation.chat.node', { key: 'tool-call', priority: -10, locale: 'chat', inject: face }, ToolCell), diff --git a/core/test/header-effects.test.js b/core/test/header-effects.test.js new file mode 100644 index 00000000..c21cd14f --- /dev/null +++ b/core/test/header-effects.test.js @@ -0,0 +1,83 @@ +// The pill's page effects (#803) are installed once over the main-view Session instead of +// riding the pill's own hooks, because the seat the pill sits in is one the shell hides on a +// blank Session. That makes them follow two sources that answer at different times: the +// sessions list knows the Session first, the roster names it only once it has loaded, and +// `blank` flips on that same Session when the first message arrives. A single "did the +// Session change" guard drops every one of those transitions, which is exactly the class of +// bug this split was meant to end: a friend's empty chat would never get its hint, and the +// local run stats would keep showing in a friend's chat. +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { test } from 'node:test' + +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') +const { installHeaderEffects } = await import('../src/client/header-effects.js') + +/** A source with the createSource shape the installer subscribes to. */ +function source(initial) { + let value = initial + const listeners = new Set() + return { + getSnapshot: () => value, + subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn) }, + set: (next) => { value = next; for (const fn of listeners) fn() }, + } +} + +test('the header effects follow each source as it answers', () => { + const doc = new JSDOM('').window.document + const sessions = source({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: true } } }) + const roster = source({ byId: {}, roomsById: {} }) + const visits = [] + const actions = { + beginVisit: (id) => visits.push(['begin', id]), + endVisit: (id) => visits.push(['end', id]), + } + const off = installHeaderEffects({ sessions, roster, actions, doc }) + assert.equal(doc.body.dataset.btFriendChat, undefined, 'nothing is a friend chat before the roster answers') + + // The roster catches up: the main view is a friend's Bot, and its chat is still empty. + roster.set({ byId: { s1: { id: 's1', remote: true, name: 'Helper' } }, roomsById: {} }) + assert.equal(doc.body.dataset.btFriendChat, '', 'the friend-chat flag follows the roster') + assert.equal(doc.body.dataset.btRemoteChat, 'empty', 'the empty-chat hint appears once the roster names the friend') + assert.ok(doc.body.style.getPropertyValue('--bt-remote-empty'), 'the hint text is set') + + // The first message arrives: the same Session stops being blank. + sessions.set({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: false } } }) + assert.equal(doc.body.dataset.btRemoteChat, undefined, 'the hint goes when the chat is no longer empty') + assert.equal(doc.body.style.getPropertyValue('--bt-remote-empty'), '', 'the hint text goes with it') + assert.equal(doc.body.dataset.btFriendChat, '', 'a friend chat still hides the local stats') + + // One visit per Session, however many times a source answers. + assert.deepEqual(visits, [['begin', 's1']], 'the visit opens once, not once per source answer') + off() + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1']], 'teardown ends the visit') +}) + +test('moving to another Session moves the effects, and a group chat sets the room flag', () => { + const doc = new JSDOM('').window.document + const sessions = source({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: true } } }) + const roster = source({ byId: {}, roomsById: {} }) + const visits = [] + const actions = { + beginVisit: (id) => visits.push(['begin', id]), + endVisit: (id) => visits.push(['end', id]), + } + const off = installHeaderEffects({ sessions, roster, actions, doc }) + roster.set({ byId: {}, roomsById: { s1: { id: 's1', name: 'Room', origin: 'group' } } }) + assert.equal(doc.body.dataset.btRoom, '', 'a group chat sets the room flag') + + // The user opens an ordinary Bot chat: no room behind it, so the room flag goes. + sessions.set({ byId: { s1: { id: 's1', retainedBy: {}, blank: true }, s2: { id: 's2', retainedBy: { mainView: 1 }, blank: true } } }) + roster.set({ byId: {}, roomsById: { s1: { id: 's1', name: 'Room', origin: 'group' } } }) + assert.equal(doc.body.dataset.btRoom, undefined, 'the old Session\'s room flag is gone') + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1'], ['begin', 's2']], 'the visit moved with the view') + + // And back into a face room: the flag returns, and the visit moves again. + roster.set({ byId: {}, roomsById: { s2: { id: 's2', name: 'Face', origin: 'face-to-face' } } }) + assert.equal(doc.body.dataset.btRoom, '', 'a face room is a room too') + off() + assert.equal(doc.body.dataset.btRoom, undefined, 'teardown clears the last Session\'s flags') + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1'], ['begin', 's2'], ['end', 's2']], 'teardown ends the open visit') +}) diff --git a/core/test/header-slot.test.js b/core/test/header-slot.test.js index 76604058..989a9635 100644 --- a/core/test/header-slot.test.js +++ b/core/test/header-slot.test.js @@ -46,17 +46,22 @@ test('the Bot pill is appended to conversation.session.header.utilities', () => assert.match(utilities[1], /\bid:\s*'bot-pill'/, 'the utilities entry carries a stable id') }) -// The shell renders .utilities only while its chrome shows (a blank Session hides it, hideChrome = blank), -// so moving the pill there dropped every effect below it: the friend-Bot empty-chat hint, the visit ledger, -// the composer placeholder, the room flag and the face @ filter would only start after the first message. -test('#803: the page effects ride conversation.session.header.corner, drawn in every state', () => { - const corner = /shadow\(\s*'conversation\.session\.header\.corner'\s*,\s*\{([^}]*)\}\s*,\s*HeaderSeats\s*\)/.exec(code) - assert.ok(corner, 'HeaderSeats must register into conversation.session.header.corner') - assert.match(corner[1], /\binject:\s*face\b/, 'the corner seat gets the same face as the pill') - const seats = bodyOf('HeaderSeats') +// The shell renders .utilities only while its chrome shows (a blank Session hides it, hideChrome = +// blank), so the pill's page effects cannot live in the pill. They must not be given a seat of their +// own either: `.corner` is the one header seat the shell draws in every state, and it is a `single` +// slot that ui-sidebar-right's ExpandButton already owns at priority 0. A second registration in the +// same cell throws (`single slot "…" already has a registration at priority 0`) out of `apply`, so +// FlyBot's whole client plugin fails to load. The effects are installed once from `apply` instead. +test('#803: the page effects are installed from apply, not parked in a slot', () => { + assert.doesNotMatch( + code, + /shadow\(\s*'conversation\.session\.header\.corner'\s*,/, + 'ds-bot must not register into conversation.session.header.corner: it is a single slot whose ' + + 'occupant is ui-sidebar-right\'s ExpandButton, and a same-priority registration throws', + ) + assert.match(code, /installHeaderEffects\(/, 'the client plugin installs the header effects from apply') const pill = bodyOf('HeaderPill') - for (const effect of ['btRemoteChat', 'btFriendChat', 'btRoom', 'beginVisit', 'holdComposerLabel', '--bt-first-sep']) { - assert.ok(seats.includes(effect), `${effect} runs in HeaderSeats`) - assert.ok(!pill.includes(effect), `${effect} does not run in the pill as well`) + for (const effect of ['btRemoteChat', 'btFriendChat', 'btRoom', 'beginVisit', 'holdComposerLabel']) { + assert.ok(!pill.includes(effect), `${effect} does not run in the pill`) } }) diff --git a/core/test/looks.test.js b/core/test/looks.test.js index ec8c0aac..2a075f95 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -63,10 +63,10 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders // it (composer-label.test.js); a friend's Bot chat hides the stats footer. - assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) + assert.match(text('header-effects.js'), /holdComposerLabel\(doc, name \? t\('Message \{name\}', \{ name \}\) : t\('Message'\)\)/) assert.match(text('composer-label.js'), /new Observer\(apply\)/) assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) - assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) + assert.match(text('header-effects.js'), /doc\.body\.dataset\.btFriendChat = ''/) assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) // #276: only the user's own Bots are offered as a Main Bot, in both selects. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0c7f3dab..32ae7bf7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -92,7 +92,9 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). -- The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The shell draws `.utilities` (and the pill in it) only while its chrome shows, which a Session without a first message hides, so everything the pill does to the page moved to `HeaderSeats` in `src/client/header.js`, registered at `conversation.session.header.corner`, the one Session seat drawn in every state: the friend-Bot empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]`, the face-room `@` filter and `--bt-first-sep`. `test/header-slot.test.js` pins both seats and that no effect of the list runs in `HeaderPill` as well; the floating header style FlyBot used to draw over the Session header is gone. +- The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone. +- What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws `.utilities` — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]` and the face-room `@` filter. `installHeaderEffects` (`src/client/header-effects.js`) is installed once from `apply` and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while `beginVisit`/`endVisit` keyed on more than the Session would open a second visit for a chat that was only just opened. `test/header-effects.test.js` drives those transitions on jsdom; `test/header-slot.test.js` pins that no such effect runs in `HeaderPill` and that FlyBot claims no other header seat. +- `conversation.session.header.corner` is **not** available as that seat: it is `single` and `ui-sidebar-right` already registers its right-sidebar button there with no `priority` (0), and `SlotCore.register` throws on a second registration in the same cell (`single slot "…" already has a registration at priority 0`), which would fail FlyBot's whole client plugin from `apply`. Only `--bt-first-sep` still rides the pill, because it needs a turn and so always has a header to be drawn in. - `HeaderPill` holds the localized **Message {name}** label on the composer with `holdComposerLabel` (`src/client/composer-label.js`) in a layout effect, so it is in place before the first paint: it sets the placeholder CSS var and writes the label on every `[data-composer-input]`'s `aria-label` and `data-placeholder`; cleanup hands each input the shell's latest own value back when the header unmounts or the session changes (#166). The shell picks its copy by phase before it knows whose chat it is (the new-session hero copy on a first open, its coding copy after a send), so a `MutationObserver` (those two attributes and child changes) rewrites them in its own callback, before the next paint, while the chat is open (#419, #499); `test/composer-label.test.js` drives it on a jsdom composer. In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. ### Face-room held bubble and overview diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 20f3e849..1ffeef90 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1154,7 +1154,7 @@ diff --git a/src/client/icons.js b/src/client/icons.js diff --git a/src/client/index.js b/src/client/index.js --- a/src/client/index.js +++ b/src/client/index.js -@@ -1,44 +1,63 @@ +@@ -1,44 +1,64 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' import { isHex, inkText, paintCss } from './inks.js' @@ -1167,10 +1167,10 @@ diff --git a/src/client/index.js b/src/client/index.js +import { installBrandChrome, installTabTitle } 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' --import { ComposerPlus, ComposerVoice, menuIcons } from './composer.js' +import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, ModelsSettingsStep, MODELS_STEP_ID, MODELS_STEP_ORDER, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' -+import { HeaderPill, HeaderSeats } from './header.js' + import { HeaderPill } from './header.js' +-import { ComposerPlus, ComposerVoice, menuIcons } from './composer.js' ++import { installHeaderEffects } from './header-effects.js' +import { ComposerIdentity, ComposerPlus, ComposerVoice, menuIcons } from './composer.js' import { CHATS_SOURCE, atSources } from './at-menu.js' +import { watchFaceMenu } from './face-at-filter.js' @@ -1411,6 +1411,24 @@ diff --git a/src/client/index.js b/src/client/index.js actions.signInEntry = () => ctx.slots.entries?.('settings.models.sign-in')?.[0] ?? null actions.canSignIn = () => Boolean(actions.signInEntry()) actions.signIn = () => ui.set(state => ({ ...state, signIn: (state.signIn ?? 0) + 1 })) +@@ -690,6 +690,17 @@ export function apply(ctx) { + const browserPages = browsers?.pages ?? createSource({}) + const browserTabs = browsers?.tabs ?? createSource({}) + const face = () => ({ actions, hooks: { roster, ui, activity, work, memoryNews, registry, turnClock, live, cards, browserPages, browserTabs, surface, update } }) ++ // What the pill does to the page follows the Session being looked at, not the seat the ++ // pill is painted in: the shell hides `.utilities` on a blank Session, so a Side effect ++ // parked in the pill stops there. Nothing is claimed for them either — `.corner`, the one ++ // header seat the shell draws in every state, already belongs to ui-sidebar-right's own ++ // button (a `single` slot registers at one priority per cell) (#803). ++ ctx.effect(() => installHeaderEffects({ ++ sessions: ctx.sessions.list, ++ roster, ++ actions, ++ doc: typeof document === 'undefined' ? undefined : document, ++ }), 'ds-bot: session header effects') + ctx.effect(() => noticeApprovals({ + work, + roster, @@ -715,10 +715,23 @@ export function apply(ctx) { } @@ -1463,7 +1481,7 @@ diff --git a/src/client/index.js b/src/client/index.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId) if (controller === undefined || span === undefined) return -@@ -752,33 +752,47 @@ export function apply(ctx) { +@@ -752,33 +752,44 @@ export function apply(ctx) { installCompiledStyles(SHELL_CSS, 'shell'), installCompiledStyles(SIDEBAR_CSS, 'sidebar'), installCompiledStyles(SECRET_CSS, 'secrets'), @@ -1508,9 +1526,6 @@ diff --git a/src/client/index.js b/src/client/index.js + // (ui-subagent) and the lineage title. FlyBot's pill is appended to the + // utilities list beside them instead of replacing the whole header. + shadow('conversation.session.header.utilities', { id: 'bot-pill', order: 100, inject: face }, HeaderPill), -+ // The shell draws .utilities only while its chrome shows (a blank Session hides it), so what the -+ // pill does to the page rides .corner, which the shell draws in every state (#803). -+ shadow('conversation.session.header.corner', { id: 'bot-header-seats', order: 100, inject: face }, HeaderSeats), shadow('conversation.chat.node', { key: 'assistant-step', priority: -10, locale: 'chat', inject: assistantFace }, AssistantCell), shadow('conversation.chat.node', { key: 'turn-trigger', priority: -10, locale: 'chat', inject: face }, TriggerCell), shadow('conversation.chat.node', { key: 'tool-call', priority: -10, locale: 'chat', inject: face }, ToolCell), @@ -2917,10 +2932,10 @@ diff --git a/test/looks.test.js b/test/looks.test.js + assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) + // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders + // it (composer-label.test.js); a friend's Bot chat hides the stats footer. -+ assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) ++ assert.match(text('header-effects.js'), /holdComposerLabel\(doc, name \? t\('Message \{name\}', \{ name \}\) : t\('Message'\)\)/) + assert.match(text('composer-label.js'), /new Observer\(apply\)/) + assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) -+ assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) ++ assert.match(text('header-effects.js'), /doc\.body\.dataset\.btFriendChat = ''/) + assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) + // #276: only the user's own Bots are offered as a Main Bot, in both selects. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index b08ad4c9..574f0ef1 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -4852,12 +4852,13 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4626,154 +4626,880 @@ function useRowEntrances(replacing) { +@@ -4624,156 +4624,882 @@ function useRowEntrances(replacing) { + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); }); - scan(); - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); +- scan(); ++ scan(); ++ return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; @@ -5304,7 +5305,9 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); -+ return () => { + return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); @@ -6576,7 +6579,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5491,60 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,28 @@ 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}} @@ -6592,38 +6595,6 @@ diff --git a/client.js b/client.js +.bt-row-settings svg{width:15px;height:15px} `; -+// src/client/composer-label.js -+var ATTRIBUTES = ["aria-label", "data-placeholder"]; -+function holdComposerLabel(doc, label) { -+ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); -+ const shell = /* @__PURE__ */ new Map(); -+ const apply2 = () => { -+ for (const el of doc.querySelectorAll("[data-composer-input]")) { -+ for (const name of ATTRIBUTES) { -+ const current = el.getAttribute(name); -+ if (current === label) continue; -+ if (!shell.has(el)) shell.set(el, {}); -+ shell.get(el)[name] = current; -+ el.setAttribute(name, label); -+ } -+ } -+ }; -+ apply2(); -+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; -+ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; -+ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); -+ return () => { -+ watcher?.disconnect(); -+ for (const [el, values] of shell) { -+ if (!el.isConnected) continue; -+ for (const [name, value] of Object.entries(values)) { -+ if (value == null) el.removeAttribute(name); -+ else el.setAttribute(name, value); -+ } -+ } -+ }; -+} -+ // src/client/header.js -var import_react9 = require("react"); +var import_react14 = require("react"); @@ -6641,7 +6612,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,21 +5512,20 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,16 +5512,16 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { @@ -6662,14 +6633,8 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react14.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react14.createElement)(MemoryIcon)), label) ); } --function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnClock, actions }) { - const roster = useRoster((value) => value); -- const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); - const firstTurns = useSessions((list) => { - const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; - return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; -@@ -5537,63 +5537,95 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe + function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { +@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -6678,79 +6643,32 @@ 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"); - }, [opening]); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const owner = roster.byId[sessionId]?.id ?? sessionId; -- const doing = useActivity((map) => map[owner]); +@@ -5548,52 +5548,38 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; -- const detailsOpen = useUi((state) => state.details === sessionId); + const detailsOpen = useUi((state) => state.details === sessionId); - const name = bot?.name ?? room2?.name ?? ""; -- const useNews = useMemoryNews ?? noNews; -- const memoryIds = bot ? [bot.id] : room2?.members ?? []; -- const notice = useMemoryNotice(memoryIds, useNews); ++ const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; + const useNews = useMemoryNews ?? noNews; + const memoryIds = bot ? [bot.id] : room2?.members ?? []; + const notice = useMemoryNotice(memoryIds, useNews); - const changed = useNews((map) => latestMemoryChange(map, memoryIds)); - (0, import_react9.useEffect)(() => { - document.body.style.setProperty("--bt-placeholder", JSON.stringify(name ? t("Message {name}", { name }) : t("Message"))); - }, [name, t("Message")]); - (0, import_react9.useEffect)(() => { -+ const blank3 = useSessions((list) => list.byId[sessionId]?.blank === true); -+ const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; -+ const remoteEmptyHint = t("Send a message. Your friend approves unless Auto-send is on."); -+ (0, import_react14.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_react14.useEffect)(() => { -+ if (!bot?.remote) return void 0; -+ document.body.dataset.btFriendChat = ""; -+ return () => { -+ delete document.body.dataset.btFriendChat; -+ }; -+ }, [bot?.remote]); -+ const composerLabel = name ? t("Message {name}", { name }) : t("Message"); -+ (0, import_react14.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); -+ (0, import_react14.useEffect)(() => { - actions.beginVisit(sessionId); - return () => actions.endVisit(sessionId); - }, [sessionId]); +- actions.beginVisit(sessionId); +- return () => actions.endVisit(sessionId); +- }, [sessionId]); - (0, import_react9.useEffect)(() => { -+ const face = room2?.origin === "face-to-face"; -+ (0, import_react14.useEffect)(() => face ? actions.filterFaceAtMenu?.(sessionId) : void 0, [sessionId, face]); -+ (0, import_react14.useEffect)(() => { - if (room2) document.body.dataset.btRoom = ""; - else delete document.body.dataset.btRoom; - return () => { - delete document.body.dataset.btRoom; - }; - }, [Boolean(room2)]); +- if (room2) document.body.dataset.btRoom = ""; +- else delete document.body.dataset.btRoom; +- return () => { +- delete document.body.dataset.btRoom; +- }; +- }, [Boolean(room2)]); - const memoryNotice = notice && changed !== null && roster.memory !== false; - return (0, import_react9.createElement)( -+ return null; -+} -+function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+ const roster = useRoster((value) => value); -+ const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); -+ const hiddenTurns = roster.exchangeTurns?.[sessionId]; -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const owner = roster.byId[sessionId]?.id ?? sessionId; -+ const doing = useActivity((map) => map[owner]); -+ const bot = roster.byId[sessionId]; -+ const room2 = roster.roomsById[sessionId]; -+ const detailsOpen = useUi((state) => state.details === sessionId); -+ const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; -+ const useNews = useMemoryNews ?? noNews; -+ const memoryIds = bot ? [bot.id] : room2?.members ?? []; -+ const notice = useMemoryNotice(memoryIds, useNews); + const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); + const memoryNotice = notice && changed2 !== null && roster.memory !== false; + return (0, import_react14.createElement)( @@ -6872,9 +6790,108 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5715,18 +5715,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5714,19 +5714,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + ); } ++// src/client/composer-label.js ++var ATTRIBUTES = ["aria-label", "data-placeholder"]; ++function holdComposerLabel(doc, label) { ++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); ++ const shell = /* @__PURE__ */ new Map(); ++ const apply2 = () => { ++ for (const el of doc.querySelectorAll("[data-composer-input]")) { ++ for (const name of ATTRIBUTES) { ++ const current = el.getAttribute(name); ++ if (current === label) continue; ++ if (!shell.has(el)) shell.set(el, {}); ++ shell.get(el)[name] = current; ++ el.setAttribute(name, label); ++ } ++ } ++ }; ++ apply2(); ++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; ++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; ++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); ++ return () => { ++ watcher?.disconnect(); ++ for (const [el, values] of shell) { ++ if (!el.isConnected) continue; ++ for (const [name, value] of Object.entries(values)) { ++ if (value == null) el.removeAttribute(name); ++ else el.setAttribute(name, value); ++ } ++ } ++ }; ++} ++ ++// src/client/header-effects.js ++function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { ++ if (!doc) return () => { ++ }; ++ const slots = /* @__PURE__ */ new Map(); ++ const run = (id, dep, setup) => { ++ const held = slots.get(id); ++ if (held !== void 0 && held.dep === dep) return; ++ held?.off(); ++ slots.set(id, { dep, off: setup() ?? (() => { ++ }) }); ++ }; ++ const apply2 = () => { ++ const list = sessions.getSnapshot(); ++ const state = roster.getSnapshot(); ++ const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0); ++ const sessionId = row?.id; ++ const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId]; ++ const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId]; ++ const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; ++ run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => { ++ if (!bot?.remote) return void 0; ++ doc.body.dataset.btFriendChat = ""; ++ return () => { ++ delete doc.body.dataset.btFriendChat; ++ }; ++ }); ++ run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { ++ if (!bot?.remote || row?.blank !== true) return void 0; ++ doc.body.dataset.btRemoteChat = "empty"; ++ doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on."))); ++ return () => { ++ delete doc.body.dataset.btRemoteChat; ++ doc.body.style.removeProperty("--bt-remote-empty"); ++ }; ++ }); ++ run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message"))); ++ run("visit", sessionId, () => { ++ if (sessionId === void 0) return void 0; ++ actions.beginVisit(sessionId); ++ return () => actions.endVisit(sessionId); ++ }); ++ run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => { ++ if (room2?.origin !== "face-to-face") return void 0; ++ const stop = actions.filterFaceAtMenu?.(sessionId); ++ return typeof stop === "function" ? stop : void 0; ++ }); ++ run("room-flag", `${sessionId}|${room2 !== void 0}`, () => { ++ if (room2 === void 0) return void 0; ++ doc.body.dataset.btRoom = ""; ++ return () => { ++ delete doc.body.dataset.btRoom; ++ }; ++ }); ++ }; ++ apply2(); ++ const offSessions = sessions.subscribe(apply2); ++ const offRoster = roster.subscribe(apply2); ++ return () => { ++ offSessions?.(); ++ offRoster?.(); ++ for (const held of slots.values()) held.off(); ++ slots.clear(); ++ }; ++} ++ // src/client/composer.js -var import_react10 = require("react"); +var import_react15 = require("react"); @@ -10535,13 +10552,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react30.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react30.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react30.createElement)( + "div", @@ -12632,9 +12649,23 @@ diff --git a/client.js b/client.js ); } --// src/client/storage.js --var STORAGE = { -- unread: "ds-bot:unread", ++// src/client/pane-width.js ++var DEFAULT_WIDTH = 520; ++var DETAILS_WIDTH = 320; ++var DETAILS_MAX = 520; ++var MIN_WIDTH = 360; ++var MIN_CHAT = 420; ++var MIN_DETAILS = 240; ++function panelWidthRange(view, viewport) { ++ const room2 = Math.max(0, viewport - MIN_CHAT); ++ if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)]; ++ const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2)); ++ return [low, Math.max(low, Math.min(DETAILS_MAX, room2))]; ++} ++ + // src/client/storage.js + var STORAGE = { + unread: "ds-bot:unread", - reactions: "ds-bot:reactions", - seen: "ds-bot:seen", - prefs: "ds-bot:prefs", @@ -12695,20 +12726,8 @@ diff --git a/client.js b/client.js - } - } catch { - } -+// src/client/pane-width.js -+var DEFAULT_WIDTH = 520; -+var DETAILS_WIDTH = 320; -+var DETAILS_MAX = 520; -+var MIN_WIDTH = 360; -+var MIN_CHAT = 420; -+var MIN_DETAILS = 240; -+function panelWidthRange(view, viewport) { -+ const room2 = Math.max(0, viewport - MIN_CHAT); -+ if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)]; -+ const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2)); -+ return [low, Math.max(low, Math.min(DETAILS_MAX, room2))]; - } - +-} +- -// src/client/member-status.js -var import_react30 = require("react"); -var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -12891,9 +12910,6 @@ diff --git a/client.js b/client.js - entry.here ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); -+// src/client/storage.js -+var STORAGE = { -+ unread: "ds-bot:unread", + reactions: "ds-bot:reactions", + seen: "ds-bot:seen", + friendLines: "ds-bot:friend-lines", @@ -14396,8 +14412,8 @@ diff --git a/client.js b/client.js + off(); + timers.clearTimeout?.(timer); + }; -+} -+ + } + +// src/client/stats-dock.js +var import_react42 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -14411,7 +14427,7 @@ diff --git a/client.js b/client.js + const { turns, steps } = params; + return text === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text; + }; - } ++} +function statsDock(entriesOf, id = STATS_ID) { + const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); + function StatsDock(props) { @@ -14423,7 +14439,7 @@ diff --git a/client.js b/client.js + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; +} +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); - ++ // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { @@ -14656,12 +14672,18 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13964,7 +13964,7 @@ function apply(ctx) { +@@ -13964,7 +13964,13 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); - const face = () => ({ actions, hooks: { roster, ui: ui2, activity, work, memoryNews, registry, turnClock, live, cards, browserPages, browserTabs, surface, update } }); + const face = () => ({ actions, hooks: { roster, ui: ui2, activity, work, memoryNews, registry: registry3, turnClock, live, cards, browserPages, browserTabs, surface, update } }); ++ ctx.effect(() => installHeaderEffects({ ++ sessions: ctx.sessions.list, ++ roster, ++ actions, ++ doc: typeof document === "undefined" ? void 0 : document ++ }), "ds-bot: session header effects"); ctx.effect(() => noticeApprovals({ work, roster, @@ -14711,7 +14733,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,47 @@ function apply(ctx) { +@@ -14019,33 +14019,44 @@ function apply(ctx) { installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -14757,9 +14779,6 @@ diff --git a/client.js b/client.js + // (ui-subagent) and the lineage title. FlyBot's pill is appended to the + // utilities list beside them instead of replacing the whole header. + shadow("conversation.session.header.utilities", { id: "bot-pill", order: 100, inject: face }, HeaderPill), -+ // The shell draws .utilities only while its chrome shows (a blank Session hides it), so what the -+ // pill does to the page rides .corner, which the shell draws in every state (#803). -+ shadow("conversation.session.header.corner", { id: "bot-header-seats", order: 100, inject: face }, HeaderSeats), shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index e9d9b413..ddbc5c6b 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -499,116 +499,57 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js diff --git a/src/client/header.js b/src/client/header.js --- a/src/client/header.js +++ b/src/client/header.js -@@ -4,8 +4,10 @@ import { currentPart, latestMemoryChange } from './text.js' +@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom' + import { currentPart, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' ++import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { ActivityIndicator } from './activity-row.js' -import { clockLabel, openingTurn, unseenSince } from './cells.js' -+import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' +import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' -+import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header -@@ -42,9 +42,13 @@ function MemoryNotice({ shown }) { - h('span', { className: 'bt-pill-mem-in', ref: inner }, h('span', { className: 'bt-pill-mem-ico' }, h(MemoryIcon)), label)) - } - --export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+// Everything the header pill does to the page — the empty-chat hint, the visit ledger, the composer -+// placeholder, the room and face flags — lives here rather than in the pill. The shell renders -+// `conversation.session.header.utilities`, and the pill with it, only while its chrome shows -+// (`!hideChrome`, the shell's `blank`), so a Session without a first message would drop every one of -+// those effects; `conversation.session.header.corner` is rendered in every state, so they ride there (#803). -+export function HeaderSeats({ sessionId, useRoster, useSessions, useUi, useTurnClock, actions }) { - const roster = useRoster(value => value) -- const title = useSessions(list => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? '') - const firstTurns = useSessions((list) => { - const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline - return Array.isArray(outline) ? outline.slice(0, 8).map(entry => entry.turn).join(',') : '' -@@ -62,32 +62,71 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus - else document.body.style.removeProperty('--bt-first-sep') - return () => document.body.style.removeProperty('--bt-first-sep') - }, [opening]) -- const running = useSessionStatus(map => map.get(sessionId)?.running === true) -- const owner = roster.byId[sessionId]?.id ?? sessionId -- const doing = useActivity(map => map[owner]) +@@ -51,8 +51,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus + }) + const hiddenTurns = roster.exchangeTurns?.[sessionId] + const visit = useUi(state => state.visits?.[sessionId]) +- // The opening separator has no turn before it, so the transcript column draws it, +- // unless the opening is unread: then the first trigger draws it under the divider. ++ // The opening separator is the one page effect that stays with the pill: it needs a turn, so ++ // the shell always draws the header for it, and it reads `useTurnClock`, which the installer ++ // in header-effects.js deliberately does not carry. + const opening = useTurnClock((state) => { + const first = openingTurn(state[sessionId], firstTurns, hiddenTurns) + return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : '' +@@ -68,26 +68,14 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] -- const detailsOpen = useUi(state => state.details === sessionId) + const detailsOpen = useUi(state => state.details === sessionId) - const name = bot?.name ?? room?.name ?? '' -- const useNews = useMemoryNews ?? noNews -- const memoryIds = bot ? [bot.id] : room?.members ?? [] -- const notice = useMemoryNotice(memoryIds, useNews) -- // While the notice shows, the pill opens the memory of the Bot that changed. -- const changed = useNews(map => latestMemoryChange(map, memoryIds)) -- // The composer placeholder is not localizable per Session, so CSS reads it from here. -+ const blank = useSessions(list => list.byId[sessionId]?.blank === true) + const name = bot?.remote + ? friendBotTitle(bot) + : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') -+ 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]) -+ // A friend's Bot runs on its owner's home: the local run stats (turns, steps, tok/s, cache) -+ // are relay turns and always 0 here, so CSS hides the shell's stats footer in that chat (#419). - useEffect(() => { + const useNews = useMemoryNews ?? noNews + const memoryIds = bot ? [bot.id] : room?.members ?? [] + const notice = useMemoryNotice(memoryIds, useNews) + // While the notice shows, the pill opens the memory of the Bot that changed. + const changed = useNews(map => latestMemoryChange(map, memoryIds)) +- // The composer placeholder is not localizable per Session, so CSS reads it from here. +- useEffect(() => { - document.body.style.setProperty('--bt-placeholder', JSON.stringify(name ? t('Message {name}', { name }) : t('Message'))) - }, [name, t('Message')]) -+ if (!bot?.remote) return undefined -+ document.body.dataset.btFriendChat = '' -+ return () => { delete document.body.dataset.btFriendChat } -+ }, [bot?.remote]) -+ // The composer placeholder is not localizable per Session, so CSS reads it from here. -+ const composerLabel = name ? t('Message {name}', { name }) : t('Message') -+ // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). -+ useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) - useEffect(() => { - 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 = '' - else delete document.body.dataset.btRoom - return () => { delete document.body.dataset.btRoom } - }, [Boolean(room)]) -+ return null -+} -+ -+export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+ const roster = useRoster(value => value) -+ const title = useSessions(list => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? '') -+ const hiddenTurns = roster.exchangeTurns?.[sessionId] -+ const running = useSessionStatus(map => map.get(sessionId)?.running === true) -+ const owner = roster.byId[sessionId]?.id ?? sessionId -+ const doing = useActivity(map => map[owner]) -+ const bot = roster.byId[sessionId] -+ const room = roster.roomsById[sessionId] -+ const detailsOpen = useUi(state => state.details === sessionId) -+ const name = bot?.remote -+ ? friendBotTitle(bot) -+ : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') -+ const useNews = useMemoryNews ?? noNews -+ const memoryIds = bot ? [bot.id] : room?.members ?? [] -+ const notice = useMemoryNotice(memoryIds, useNews) -+ // While the notice shows, the pill opens the memory of the Bot that changed. -+ const changed = useNews(map => latestMemoryChange(map, memoryIds)) +- useEffect(() => { +- actions.beginVisit(sessionId) +- return () => actions.endVisit(sessionId) +- }, [sessionId]) +- // Group chats have no voice mode, so the composer CSS needs to know the Session kind. +- useEffect(() => { +- if (room) document.body.dataset.btRoom = '' +- else delete document.body.dataset.btRoom +- return () => { delete document.body.dataset.btRoom } +- }, [Boolean(room)]) const memoryNotice = notice && changed !== null && roster.memory !== false return h('div', { className: 'bt-head' }, h('button', { diff --git a/patches/ds-bot/files/src/client/header-effects.js b/patches/ds-bot/files/src/client/header-effects.js new file mode 100644 index 00000000..b27591f5 --- /dev/null +++ b/patches/ds-bot/files/src/client/header-effects.js @@ -0,0 +1,95 @@ +// The Session's page effects (#803) live here rather than in header.js so they carry no +// React: they are installed once from `apply` and only follow two plain sources. +import { t } from './i18n.js' +import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { holdComposerLabel } from './composer-label.js' + +// The pill's effects belong to the *Session being looked at*, not to the pill being +// painted. They used to sit in HeaderPill's own effects, which tied them to wherever +// the pill happened to render: when the pill moved into a seat the shell hides on a +// blank Session, a friend's empty chat lost its hint, the local stats footer stopped +// hiding in a friend's chat (#419), a new group chat lost its voice styling, the visit +// ledger stopped recording, the composer label went back to the shell's copy and a +// face room's "@" listed the wrong people (#305). Installing them once, over the main +// view's Session, keeps every one of them independent of the pill's seat. +/** + * Keep the main-view Session's body flags, visit ledger, composer label and face "@" + * filter in step with the Session the user is looking at, for as long as FlyBot runs. + * @param root0 - the sessions list, the roster, their live sources and the client actions. + * @returns teardown for the subscriptions and the last Session's effects. + */ +export function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { + if (!doc) return () => {} + // One slot per effect, keyed by that effect's own dependencies, the way a React + // `useEffect` dependency list would be. A single "did the Session change" guard is + // not enough: the roster answers after the sessions list does, so on the first pass a + // friend's chat has no `bot` yet and its flags would never be set, and `blank` flips + // on the same Session when the first message arrives. Restarting everything on either + // would also record a second visit for a Session that had only just been opened. + const slots = new Map() + const run = (id, dep, setup) => { + const held = slots.get(id) + if (held !== undefined && held.dep === dep) return + held?.off() + slots.set(id, { dep, off: setup() ?? (() => {}) }) + } + const apply = () => { + const list = sessions.getSnapshot() + const state = roster.getSnapshot() + const row = Object.values(list?.byId ?? {}).find(entry => (entry?.retainedBy?.mainView ?? 0) > 0) + const sessionId = row?.id + const bot = sessionId === undefined ? undefined : state?.byId?.[sessionId] + const room = sessionId === undefined ? undefined : state?.roomsById?.[sessionId] + const name = bot?.remote + ? friendBotTitle(bot) + : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') + // A friend's Bot runs on its owner's home, so the local run stats are relay turns + // (always 0): CSS hides the shell's stats footer in that chat (#419). + run('friend-chat', `${sessionId}|${bot?.remote === true}`, () => { + if (!bot?.remote) return undefined + doc.body.dataset.btFriendChat = '' + return () => { delete doc.body.dataset.btFriendChat } + }) + // The hint is for an empty chat, which is exactly the state whose header seat the + // shell hides (#173). + run('remote-empty', `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { + if (!bot?.remote || row?.blank !== true) return undefined + doc.body.dataset.btRemoteChat = 'empty' + doc.body.style.setProperty('--bt-remote-empty', JSON.stringify(t('Send a message. Your friend approves unless Auto-send is on.'))) + return () => { + delete doc.body.dataset.btRemoteChat + doc.body.style.removeProperty('--bt-remote-empty') + } + }) + // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). + run('composer-label', `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t('Message {name}', { name }) : t('Message'))) + // Keyed by the Session alone: a roster answer that only names this Bot must not + // close and reopen the visit, which would lose what the visit had already recorded. + run('visit', sessionId, () => { + if (sessionId === undefined) return undefined + actions.beginVisit(sessionId) + return () => actions.endVisit(sessionId) + }) + // A face room's "@" lists the room's people and Bots, not the shell's Sessions (#305). + run('face-at-menu', `${sessionId}|${room?.origin === 'face-to-face'}`, () => { + if (room?.origin !== 'face-to-face') return undefined + const stop = actions.filterFaceAtMenu?.(sessionId) + return typeof stop === 'function' ? stop : undefined + }) + // Group chats have no voice mode, so the composer CSS needs to know the Session kind. + run('room-flag', `${sessionId}|${room !== undefined}`, () => { + if (room === undefined) return undefined + doc.body.dataset.btRoom = '' + return () => { delete doc.body.dataset.btRoom } + }) + } + apply() + const offSessions = sessions.subscribe(apply) + const offRoster = roster.subscribe(apply) + return () => { + offSessions?.() + offRoster?.() + for (const held of slots.values()) held.off() + slots.clear() + } +} diff --git a/patches/ds-bot/files/test/header-effects.test.js b/patches/ds-bot/files/test/header-effects.test.js new file mode 100644 index 00000000..c21cd14f --- /dev/null +++ b/patches/ds-bot/files/test/header-effects.test.js @@ -0,0 +1,83 @@ +// The pill's page effects (#803) are installed once over the main-view Session instead of +// riding the pill's own hooks, because the seat the pill sits in is one the shell hides on a +// blank Session. That makes them follow two sources that answer at different times: the +// sessions list knows the Session first, the roster names it only once it has loaded, and +// `blank` flips on that same Session when the first message arrives. A single "did the +// Session change" guard drops every one of those transitions, which is exactly the class of +// bug this split was meant to end: a friend's empty chat would never get its hint, and the +// local run stats would keep showing in a friend's chat. +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { test } from 'node:test' + +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') +const { installHeaderEffects } = await import('../src/client/header-effects.js') + +/** A source with the createSource shape the installer subscribes to. */ +function source(initial) { + let value = initial + const listeners = new Set() + return { + getSnapshot: () => value, + subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn) }, + set: (next) => { value = next; for (const fn of listeners) fn() }, + } +} + +test('the header effects follow each source as it answers', () => { + const doc = new JSDOM('').window.document + const sessions = source({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: true } } }) + const roster = source({ byId: {}, roomsById: {} }) + const visits = [] + const actions = { + beginVisit: (id) => visits.push(['begin', id]), + endVisit: (id) => visits.push(['end', id]), + } + const off = installHeaderEffects({ sessions, roster, actions, doc }) + assert.equal(doc.body.dataset.btFriendChat, undefined, 'nothing is a friend chat before the roster answers') + + // The roster catches up: the main view is a friend's Bot, and its chat is still empty. + roster.set({ byId: { s1: { id: 's1', remote: true, name: 'Helper' } }, roomsById: {} }) + assert.equal(doc.body.dataset.btFriendChat, '', 'the friend-chat flag follows the roster') + assert.equal(doc.body.dataset.btRemoteChat, 'empty', 'the empty-chat hint appears once the roster names the friend') + assert.ok(doc.body.style.getPropertyValue('--bt-remote-empty'), 'the hint text is set') + + // The first message arrives: the same Session stops being blank. + sessions.set({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: false } } }) + assert.equal(doc.body.dataset.btRemoteChat, undefined, 'the hint goes when the chat is no longer empty') + assert.equal(doc.body.style.getPropertyValue('--bt-remote-empty'), '', 'the hint text goes with it') + assert.equal(doc.body.dataset.btFriendChat, '', 'a friend chat still hides the local stats') + + // One visit per Session, however many times a source answers. + assert.deepEqual(visits, [['begin', 's1']], 'the visit opens once, not once per source answer') + off() + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1']], 'teardown ends the visit') +}) + +test('moving to another Session moves the effects, and a group chat sets the room flag', () => { + const doc = new JSDOM('').window.document + const sessions = source({ byId: { s1: { id: 's1', retainedBy: { mainView: 1 }, blank: true } } }) + const roster = source({ byId: {}, roomsById: {} }) + const visits = [] + const actions = { + beginVisit: (id) => visits.push(['begin', id]), + endVisit: (id) => visits.push(['end', id]), + } + const off = installHeaderEffects({ sessions, roster, actions, doc }) + roster.set({ byId: {}, roomsById: { s1: { id: 's1', name: 'Room', origin: 'group' } } }) + assert.equal(doc.body.dataset.btRoom, '', 'a group chat sets the room flag') + + // The user opens an ordinary Bot chat: no room behind it, so the room flag goes. + sessions.set({ byId: { s1: { id: 's1', retainedBy: {}, blank: true }, s2: { id: 's2', retainedBy: { mainView: 1 }, blank: true } } }) + roster.set({ byId: {}, roomsById: { s1: { id: 's1', name: 'Room', origin: 'group' } } }) + assert.equal(doc.body.dataset.btRoom, undefined, 'the old Session\'s room flag is gone') + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1'], ['begin', 's2']], 'the visit moved with the view') + + // And back into a face room: the flag returns, and the visit moves again. + roster.set({ byId: {}, roomsById: { s2: { id: 's2', name: 'Face', origin: 'face-to-face' } } }) + assert.equal(doc.body.dataset.btRoom, '', 'a face room is a room too') + off() + assert.equal(doc.body.dataset.btRoom, undefined, 'teardown clears the last Session\'s flags') + assert.deepEqual(visits, [['begin', 's1'], ['end', 's1'], ['begin', 's2'], ['end', 's2']], 'teardown ends the open visit') +}) diff --git a/patches/ds-bot/files/test/header-slot.test.js b/patches/ds-bot/files/test/header-slot.test.js index 76604058..989a9635 100644 --- a/patches/ds-bot/files/test/header-slot.test.js +++ b/patches/ds-bot/files/test/header-slot.test.js @@ -46,17 +46,22 @@ test('the Bot pill is appended to conversation.session.header.utilities', () => assert.match(utilities[1], /\bid:\s*'bot-pill'/, 'the utilities entry carries a stable id') }) -// The shell renders .utilities only while its chrome shows (a blank Session hides it, hideChrome = blank), -// so moving the pill there dropped every effect below it: the friend-Bot empty-chat hint, the visit ledger, -// the composer placeholder, the room flag and the face @ filter would only start after the first message. -test('#803: the page effects ride conversation.session.header.corner, drawn in every state', () => { - const corner = /shadow\(\s*'conversation\.session\.header\.corner'\s*,\s*\{([^}]*)\}\s*,\s*HeaderSeats\s*\)/.exec(code) - assert.ok(corner, 'HeaderSeats must register into conversation.session.header.corner') - assert.match(corner[1], /\binject:\s*face\b/, 'the corner seat gets the same face as the pill') - const seats = bodyOf('HeaderSeats') +// The shell renders .utilities only while its chrome shows (a blank Session hides it, hideChrome = +// blank), so the pill's page effects cannot live in the pill. They must not be given a seat of their +// own either: `.corner` is the one header seat the shell draws in every state, and it is a `single` +// slot that ui-sidebar-right's ExpandButton already owns at priority 0. A second registration in the +// same cell throws (`single slot "…" already has a registration at priority 0`) out of `apply`, so +// FlyBot's whole client plugin fails to load. The effects are installed once from `apply` instead. +test('#803: the page effects are installed from apply, not parked in a slot', () => { + assert.doesNotMatch( + code, + /shadow\(\s*'conversation\.session\.header\.corner'\s*,/, + 'ds-bot must not register into conversation.session.header.corner: it is a single slot whose ' + + 'occupant is ui-sidebar-right\'s ExpandButton, and a same-priority registration throws', + ) + assert.match(code, /installHeaderEffects\(/, 'the client plugin installs the header effects from apply') const pill = bodyOf('HeaderPill') - for (const effect of ['btRemoteChat', 'btFriendChat', 'btRoom', 'beginVisit', 'holdComposerLabel', '--bt-first-sep']) { - assert.ok(seats.includes(effect), `${effect} runs in HeaderSeats`) - assert.ok(!pill.includes(effect), `${effect} does not run in the pill as well`) + for (const effect of ['btRemoteChat', 'btFriendChat', 'btRoom', 'beginVisit', 'holdComposerLabel']) { + assert.ok(!pill.includes(effect), `${effect} does not run in the pill`) } })