From 72e421eb88925a180661ab7bacfc24dc90434ab2 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:18:12 +0800 Subject: [PATCH] Draw a role-pool entry as @id and its rank, not a Dossier control The pool is a rank list, not a Bot the board holds a dossier answer for: project-team answers rows keyed by the Project's own Bots, so the old pool control opened a panel for a row the host never sent. The Chief and my-agent rows keep their Dossier control (#1864). --- changelog.d/1892-pool-no-dossier.changed.md | 1 + core/client.js | 13 +---- core/src/client/project-board.js | 10 ++-- core/test/project-board-panels.test.js | 35 ++++++++++++ docs/subsystems/core-client.md | 8 +-- patches/ds-bot/0008-client-bundle.patch | 54 ++++++++----------- .../ds-bot/files/src/client/project-board.js | 10 ++-- .../files/test/project-board-panels.test.js | 35 ++++++++++++ 8 files changed, 107 insertions(+), 59 deletions(-) create mode 100644 changelog.d/1892-pool-no-dossier.changed.md diff --git a/changelog.d/1892-pool-no-dossier.changed.md b/changelog.d/1892-pool-no-dossier.changed.md new file mode 100644 index 000000000..0e13e7783 --- /dev/null +++ b/changelog.d/1892-pool-no-dossier.changed.md @@ -0,0 +1 @@ +Project board: a role-pool entry in the team block reads `@id` beside its rank instead of a Dossier control — `project-team` answers rows keyed by the Project's own Bots, so the old pool click opened a dossier the host never sent; the Chief and my-agent rows keep theirs ([#1892](https://github.com/ThinkFlowLab/FlyBot/issues/1892)). diff --git a/core/client.js b/core/client.js index fc19713fe..84707c182 100644 --- a/core/client.js +++ b/core/client.js @@ -10966,18 +10966,7 @@ var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; function ProjectTeam({ project, onOpenBot, onOpenDossier }) { const view = projectTeamView(project); - const poolId = (id) => (0, import_react19.createElement)("button", { - key: id, - type: "button", - className: "bt-project-team-dossier", - "data-dossier-role": "pool", - "data-dossier-bot": id, - "aria-label": t("Open {name}'s dossier", { name: id }), - onClick: (event) => { - event?.stopPropagation(); - onOpenDossier?.({ role: "pool", label: t("Role pool"), botId: id }); - } - }, `@${id}`); + const poolId = (id) => (0, import_react19.createElement)("span", { key: id }, `@${id}`); const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)( "span", { className: "bt-project-team-roles" }, diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 41a062d6a..afbf28a89 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -614,12 +614,10 @@ const POOL_RANKS = [['owner', 'owner'], ['maintainer', 'maintainers'], ['member' export function ProjectTeam({ project, onOpenBot, onOpenDossier }) { const view = projectTeamView(project) - const poolId = (id) => h('button', { - key: id, type: 'button', className: 'bt-project-team-dossier', - 'data-dossier-role': 'pool', 'data-dossier-bot': id, - 'aria-label': t('Open {name}\'s dossier', { name: id }), - onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role: 'pool', label: t('Role pool'), botId: id }) }, - }, `@${id}`) + // #1892: a pool entry is a rank, not a Bot this board holds a dossier answer for — `project-team` + // answers rows keyed by the Project's own Bots, so the old control opened a row the host never + // sent. The Chief and my-agent rows above keep theirs. + const poolId = (id) => h('span', { key: id }, `@${id}`) const ranks = view.empty ? h('span', { className: 'bt-project-missing' }, t('No one in this pool yet.')) : h('span', { className: 'bt-project-team-roles' }, diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js index 907397089..6f4a2de11 100644 --- a/core/test/project-board-panels.test.js +++ b/core/test/project-board-panels.test.js @@ -537,6 +537,41 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1892: the role pool is a rank list, not a Bot the board holds a dossier answer for. +// `project-team` answers rows keyed by the Project's own Bots, so a pool click asked for a row the +// host never sent and drew an empty panel. A pool entry now reads `@id` beside its rank; the Chief +// and my-agent rows keep the Dossier control they have had since #1864. +test('#1892: a role-pool entry reads @id and its rank, not a Dossier control', () => { + resetSlots() + const opened = [] + const tree = render(ProjectBoardPane, { project: team(), rows, canManage: false, onOpenDossier: value => opened.push(value) }) + const pool = teamRow(tree, 'pool') + assert.match(text(pool), /@bot-maint/, 'the pool lists the maintainer the record stores') + assert.match(text(pool), /@bot-member/, 'and the member') + assert.match(text(pool), /Owner:|Maintainer:|Member:/, 'each entry keeps its rank label') + assert.match(text(pool), /My role/, "and the caller's own rank line stays beside them") + + const poolNodes = all(pool) + const dossierish = (node) => { + const className = String(node.props?.className ?? '').split(/\s+/) + return node.props?.['data-dossier-role'] !== undefined + || node.props?.['data-dossier-bot'] !== undefined + || className.includes('bt-project-team-dossier') + || /dossier/i.test(String(node.props?.['aria-label'] ?? '')) + } + assert.deepEqual(poolNodes.filter(dossierish).map(node => text(node)), [], 'no pool entry carries a Dossier control') + for (const node of poolNodes) node.props?.onClick?.() + assert.deepEqual(opened.filter(entry => entry.role === 'pool'), [], 'and no pool id ever asks for a dossier') + + const chief = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + const agent = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-assistant') + assert.ok(chief, 'the Chief row keeps its dossier control') + assert.ok(agent, 'my agent keeps its dossier control') + chief.props.onClick() + agent.props.onClick() + assert.deepEqual(opened.map(entry => entry.role), ['chief', 'agent'], 'and each still asks for its own row') +}) + test('the dossier panel draws Persona, Memory and Skills, and stays empty for a null row', () => { resetSlots() const drawn = text(render(ProjectDossierPanel, { row: dossier(), label: 'Project Chief' })) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 967e366d6..e57dba3fb 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -316,14 +316,16 @@ spawn failed — stays drawn and reads *Not set*, so this slice never waits on # live in `src/client/role-labels.js`, which the sidebar and this block both print. Tests: `core/test/project-board-panels.test.js`. -The **dossier** (#1864) is the read-only half beside those rows. Each team row carries a `Dossier` -control (`data-dossier-bot`, also for a pool member), and `ProjectDossierPanel` draws three sections +The **dossier** (#1864) is the read-only half beside those rows. Each Bot row carries a `Dossier` +control (`data-dossier-bot`), and `ProjectDossierPanel` draws three sections from the one `project-team` answer the overlay reads with the board: **Persona** (the Bot's instructions, role and model), **Memory** (the `MEMORY.md` head and the topic names) and **Skills** (the attached `skill` capability names). The panel never re-fetches per section, and a `null` row — the host's answer for a deleted Bot — shows *Not set* with nothing invented. `readProjectBoard` keeps the dossier on the same read as the rows, so the board and the panel cannot disagree, and the -"my agent" row uses the same panel. The entry in the owner's own Bot chat is +"my agent" row uses the same panel. A role-pool entry draws none: since #1892 it is plain `@id` +beside its rank, because `project-team` answers rows keyed by the Project's own Bots and the old +control asked for a row the host never sent. The entry in the owner's own Bot chat is `ProjectAgentEntry` in the header: the roster already carries `bot.projectId`, the entry reads `project-read` once for `assistant.botId` and opens that Session through `actions.openSession`; the assistant is not a special case of the board, and the Project page keeps its own row. Tests: diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 272b77d9c..070787ea6 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -7527,22 +7527,11 @@ diff --git a/client.js b/client.js + setBusy(""); } - }); -+ }; -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-file-chips" }, -+ files.map((file) => (0, import_react9.createElement)( -+ "button", -+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, -+ `📎 ${file.name} · ${fileSize(file.size)}` -+ )), -+ error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); - } +-} -function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; - return (0, import_react5.createElement)( -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { ++ }; + return (0, import_react9.createElement)( "div", - { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, @@ -7555,6 +7544,18 @@ diff --git a/client.js b/client.js - className: "bt-secret-seg", - onClick: () => onChange(id) - }, text)) ++ { className: "bt-file-chips" }, ++ files.map((file) => (0, import_react9.createElement)( ++ "button", ++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, ++ `📎 ${file.name} · ${fileSize(file.size)}` ++ )), ++ error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ++ ); ++} ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { ++ return (0, import_react9.createElement)( ++ "div", + { className: "bt-event" }, + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null @@ -9032,10 +9033,6 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); -+} -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } -function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { - if (whole && bots.length === 1) { @@ -9061,6 +9058,10 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(BotMark, { bot, size: 16 }), - bot.name - )); ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); ++} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); @@ -9853,7 +9854,7 @@ diff --git a/client.js b/client.js + const custom = typeof reply.custom === "string" ? reply.custom.trim() : ""; + if (selected.length === 0 && custom === "") return void 0; + return Promise.resolve(actions.answerQuestion(card.sessionId, card.question.id, { selected, custom })); -+ } + } + if (card?.kind === "task" && card.status === "input") { + const text2 = typeof choice === "string" ? choice.trim() : ""; + const questionId = card.task?.inputRequest?.id; @@ -9865,7 +9866,7 @@ diff --git a/client.js b/client.js + const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; + if (note === "") return void 0; + return Promise.resolve(actions.sendBackTask(card.task.id, note)); - } ++ } + if (card?.kind === "approval" && card.approval?.answerable === true) { + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } @@ -12247,7 +12248,7 @@ diff --git a/client.js b/client.js setOpen(false); }; const onKey = (event) => { -@@ -5097,412 +5097,1340 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5097,412 +5097,1329 @@ function UpdateButton({ useUpdate, actions }) { window.removeEventListener("keydown", onKey, true); }; }, [open]); @@ -13280,18 +13281,7 @@ diff --git a/client.js b/client.js +var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]]; +function ProjectTeam({ project, onOpenBot, onOpenDossier }) { + const view = projectTeamView(project); -+ const poolId = (id) => (0, import_react19.createElement)("button", { -+ key: id, -+ type: "button", -+ className: "bt-project-team-dossier", -+ "data-dossier-role": "pool", -+ "data-dossier-bot": id, -+ "aria-label": t("Open {name}'s dossier", { name: id }), -+ onClick: (event) => { -+ event?.stopPropagation(); -+ onOpenDossier?.({ role: "pool", label: t("Role pool"), botId: id }); -+ } -+ }, `@${id}`); ++ const poolId = (id) => (0, import_react19.createElement)("span", { key: id }, `@${id}`); + const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)( + "span", + { className: "bt-project-team-roles" }, diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 41a062d6a..afbf28a89 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -614,12 +614,10 @@ const POOL_RANKS = [['owner', 'owner'], ['maintainer', 'maintainers'], ['member' export function ProjectTeam({ project, onOpenBot, onOpenDossier }) { const view = projectTeamView(project) - const poolId = (id) => h('button', { - key: id, type: 'button', className: 'bt-project-team-dossier', - 'data-dossier-role': 'pool', 'data-dossier-bot': id, - 'aria-label': t('Open {name}\'s dossier', { name: id }), - onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role: 'pool', label: t('Role pool'), botId: id }) }, - }, `@${id}`) + // #1892: a pool entry is a rank, not a Bot this board holds a dossier answer for — `project-team` + // answers rows keyed by the Project's own Bots, so the old control opened a row the host never + // sent. The Chief and my-agent rows above keep theirs. + const poolId = (id) => h('span', { key: id }, `@${id}`) const ranks = view.empty ? h('span', { className: 'bt-project-missing' }, t('No one in this pool yet.')) : h('span', { className: 'bt-project-team-roles' }, diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js index 907397089..6f4a2de11 100644 --- a/patches/ds-bot/files/test/project-board-panels.test.js +++ b/patches/ds-bot/files/test/project-board-panels.test.js @@ -537,6 +537,41 @@ test('the board reads the dossier from exactly one project-team call', async () assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief']) }) +// #1892: the role pool is a rank list, not a Bot the board holds a dossier answer for. +// `project-team` answers rows keyed by the Project's own Bots, so a pool click asked for a row the +// host never sent and drew an empty panel. A pool entry now reads `@id` beside its rank; the Chief +// and my-agent rows keep the Dossier control they have had since #1864. +test('#1892: a role-pool entry reads @id and its rank, not a Dossier control', () => { + resetSlots() + const opened = [] + const tree = render(ProjectBoardPane, { project: team(), rows, canManage: false, onOpenDossier: value => opened.push(value) }) + const pool = teamRow(tree, 'pool') + assert.match(text(pool), /@bot-maint/, 'the pool lists the maintainer the record stores') + assert.match(text(pool), /@bot-member/, 'and the member') + assert.match(text(pool), /Owner:|Maintainer:|Member:/, 'each entry keeps its rank label') + assert.match(text(pool), /My role/, "and the caller's own rank line stays beside them") + + const poolNodes = all(pool) + const dossierish = (node) => { + const className = String(node.props?.className ?? '').split(/\s+/) + return node.props?.['data-dossier-role'] !== undefined + || node.props?.['data-dossier-bot'] !== undefined + || className.includes('bt-project-team-dossier') + || /dossier/i.test(String(node.props?.['aria-label'] ?? '')) + } + assert.deepEqual(poolNodes.filter(dossierish).map(node => text(node)), [], 'no pool entry carries a Dossier control') + for (const node of poolNodes) node.props?.onClick?.() + assert.deepEqual(opened.filter(entry => entry.role === 'pool'), [], 'and no pool id ever asks for a dossier') + + const chief = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + const agent = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-assistant') + assert.ok(chief, 'the Chief row keeps its dossier control') + assert.ok(agent, 'my agent keeps its dossier control') + chief.props.onClick() + agent.props.onClick() + assert.deepEqual(opened.map(entry => entry.role), ['chief', 'agent'], 'and each still asks for its own row') +}) + test('the dossier panel draws Persona, Memory and Skills, and stays empty for a null row', () => { resetSlots() const drawn = text(render(ProjectDossierPanel, { row: dossier(), label: 'Project Chief' }))