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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog.d/803-subagent-catalog-visible.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
188 changes: 106 additions & 82 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(",") : "";
Expand All @@ -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]);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
95 changes: 95 additions & 0 deletions core/src/client/header-effects.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading