From 4da46bff969781d97398f3d2fff7d12ddb9d1d63 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:41:45 +0800 Subject: [PATCH 1/2] Keep a friend chat hidden when the user hid it The remote-chat sync treated a user's sidebar hide (roster.setFlags) as if the Bot had left the published list, so any publish of the friends' list cleared bot.hidden and the chat returned. Remember which chats were gone before open() clears the marker, and touch hidden only on a real change of gone: a left Bot still hides and a returning one still reappears, but a chat the user hid stays hidden. --- changelog.d/hidden-remote-chat-sticks.fixed.md | 1 + core/src/host/remote-chat.js | 12 +++++++++--- core/test/remote-chat.test.js | 18 ++++++++++++++++++ docs/subsystems/core-host.md | 1 + patches/ds-bot/files/src/host/remote-chat.js | 12 +++++++++--- patches/ds-bot/files/test/remote-chat.test.js | 18 ++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 changelog.d/hidden-remote-chat-sticks.fixed.md diff --git a/changelog.d/hidden-remote-chat-sticks.fixed.md b/changelog.d/hidden-remote-chat-sticks.fixed.md new file mode 100644 index 000000000..b560b6ee7 --- /dev/null +++ b/changelog.d/hidden-remote-chat-sticks.fixed.md @@ -0,0 +1 @@ +Hiding a friend's Bot chat from the sidebar ("Hide from sidebar") now sticks: the remote-chat sync no longer clears that hide on the next publish of the friends' list, while a chat whose Bot left still disappears and a returning Bot still reappears. diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 40aa529d5..293c3a8dd 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -226,6 +226,13 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { + // `open()` clears `remote.gone` on a chat that is published again, so remember which chats + // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid + // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or + // every publish would bring it back, and a returning chat still reappears (its `gone` hide + // is dropped). + const goneBefore = new Map() + for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() for (const member of list) { if (isHomeLevelRemote(member)) continue @@ -236,9 +243,8 @@ export function install(rt) { for (const bot of chats()) { if (bot.remote.plugin !== plugin) continue const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) - const wasGone = Boolean(bot.remote.gone) - const wasHidden = Boolean(bot.hidden) - if (wasGone !== gone || wasHidden !== gone) { + const wasGone = goneBefore.get(bot.id) ?? Boolean(bot.remote.gone) + if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone if (gone) bot.hidden = true diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 9abf53b19..f6cb05fbc 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -62,6 +62,24 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.c.hidden, true) }) +test('sync keeps a chat the user hid while the Bot is still published', async () => { + const state = { + bots: { + a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, + { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }, + ]) + // A friend's chat hidden from the sidebar stays hidden; only leaving the published list does (#hide). + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 31d7fe02a..3f90a2a38 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -192,6 +192,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). +- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. - `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 40aa529d5..293c3a8dd 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -226,6 +226,13 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { + // `open()` clears `remote.gone` on a chat that is published again, so remember which chats + // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid + // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or + // every publish would bring it back, and a returning chat still reappears (its `gone` hide + // is dropped). + const goneBefore = new Map() + for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() for (const member of list) { if (isHomeLevelRemote(member)) continue @@ -236,9 +243,8 @@ export function install(rt) { for (const bot of chats()) { if (bot.remote.plugin !== plugin) continue const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) - const wasGone = Boolean(bot.remote.gone) - const wasHidden = Boolean(bot.hidden) - if (wasGone !== gone || wasHidden !== gone) { + const wasGone = goneBefore.get(bot.id) ?? Boolean(bot.remote.gone) + if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone if (gone) bot.hidden = true diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 9abf53b19..f6cb05fbc 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -62,6 +62,24 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.c.hidden, true) }) +test('sync keeps a chat the user hid while the Bot is still published', async () => { + const state = { + bots: { + a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, + { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }, + ]) + // A friend's chat hidden from the sidebar stays hidden; only leaving the published list does (#hide). + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') From 538d97edd57f0f6657427b81de19d55d3781a23e Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 21:38:46 +0800 Subject: [PATCH 2/2] Keep a user's chat hide across a Bot leaving and returning sync hid a chat when its Bot left the published list and deleted `hidden` when it came back, so a hide the user had set before the Bot left was dropped too: on `hidden` alone the two hides are indistinguishable (#631). Record the sync's own hide in `remote.hiddenByGone` (only when the chat was not already hidden) and, on return, delete `hidden` only when that marker is there. Two cases in core/test/remote-chat.test.js cover both directions. --- .../hidden-remote-chat-sticks.fixed.md | 2 +- core/src/host/remote-chat.js | 12 +++++- core/test/remote-chat.test.js | 41 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/files/src/host/remote-chat.js | 12 +++++- patches/ds-bot/files/test/remote-chat.test.js | 41 +++++++++++++++++++ 6 files changed, 104 insertions(+), 6 deletions(-) diff --git a/changelog.d/hidden-remote-chat-sticks.fixed.md b/changelog.d/hidden-remote-chat-sticks.fixed.md index b560b6ee7..06559e739 100644 --- a/changelog.d/hidden-remote-chat-sticks.fixed.md +++ b/changelog.d/hidden-remote-chat-sticks.fixed.md @@ -1 +1 @@ -Hiding a friend's Bot chat from the sidebar ("Hide from sidebar") now sticks: the remote-chat sync no longer clears that hide on the next publish of the friends' list, while a chat whose Bot left still disappears and a returning Bot still reappears. +Hiding a friend's Bot chat from the sidebar ("Hide from sidebar") now sticks: the remote-chat sync no longer clears that hide on the next publish of the friends' list, and a hide the user set now survives the Bot leaving the published list and coming back, while a chat whose Bot left still disappears and a returning Bot still reappears ([#611](https://github.com/ThinkFlowLab/FlyBot/pull/611), [#631](https://github.com/ThinkFlowLab/FlyBot/issues/631)). diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 293c3a8dd..0f46c5c3d 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -247,8 +247,16 @@ export function install(rt) { if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) bot.hidden = true - else delete bot.hidden + if (gone) { + // Remember when *we* hide the chat because the Bot left the published list: on `hidden` + // alone that is indistinguishable from a hide the user set, so the return below would + // drop the user's own hide too (#631). + if (bot.hidden !== true) bot.remote.hiddenByGone = true + bot.hidden = true + } else if (bot.remote.hiddenByGone === true) { + delete bot.hidden + delete bot.remote.hiddenByGone + } changed = true } } diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index f6cb05fbc..a00b799f2 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -80,6 +80,47 @@ test('sync keeps a chat the user hid while the Bot is still published', async () assert.equal(state.bots.b.hidden, true) }) +// #631: `hidden` alone cannot tell "the user hid this" from "we hid this because the Bot left". +test('a hide the user set survives the Bot leaving the published list and coming back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + // The hide was the user's, so the return must not drop it. + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + +test('a hide imposed because the Bot left is dropped when it comes back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3f90a2a38..85c953b1c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -192,7 +192,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). -- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. +- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). - `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 293c3a8dd..0f46c5c3d 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -247,8 +247,16 @@ export function install(rt) { if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) bot.hidden = true - else delete bot.hidden + if (gone) { + // Remember when *we* hide the chat because the Bot left the published list: on `hidden` + // alone that is indistinguishable from a hide the user set, so the return below would + // drop the user's own hide too (#631). + if (bot.hidden !== true) bot.remote.hiddenByGone = true + bot.hidden = true + } else if (bot.remote.hiddenByGone === true) { + delete bot.hidden + delete bot.remote.hiddenByGone + } changed = true } } diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index f6cb05fbc..a00b799f2 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -80,6 +80,47 @@ test('sync keeps a chat the user hid while the Bot is still published', async () assert.equal(state.bots.b.hidden, true) }) +// #631: `hidden` alone cannot tell "the user hid this" from "we hid this because the Bot left". +test('a hide the user set survives the Bot leaving the published list and coming back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + // The hide was the user's, so the return must not drop it. + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + +test('a hide imposed because the Bot left is dropped when it comes back', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.b.remote.hiddenByGone, true) + await rt.remoteChats.sync('social', [member]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(state.bots.b.remote.hiddenByGone, undefined) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8')