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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/hidden-remote-chat-sticks.fixed.md
Original file line number Diff line number Diff line change
@@ -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, 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)).
24 changes: 19 additions & 5 deletions core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -236,13 +243,20 @@ 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
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
}
}
Expand Down
59 changes: 59 additions & 0 deletions core/test/remote-chat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,65 @@ 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)
})

// #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')
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<Bot> (<owner>)`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older `<Bot> · <owner>'s Bot` / `<owner>'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. 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).
Expand Down
24 changes: 19 additions & 5 deletions patches/ds-bot/files/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -236,13 +243,20 @@ 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
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
}
}
Expand Down
59 changes: 59 additions & 0 deletions patches/ds-bot/files/test/remote-chat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,65 @@ 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)
})

// #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')
Expand Down
Loading