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
1 change: 1 addition & 0 deletions changelog.d/956-sidebar-keeps-ended-share.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A friend's Bot that leaves your shared list no longer takes its whole conversation off the sidebar: the row stays, marked as no longer shared, with its history and its way back, and it returns as an ordinary chat when the share comes back. Hiding a chat yourself still removes the row, and the two states stay apart in the state file ([#956](https://github.com/ThinkFlowLab/FlyBot/issues/956)).
12 changes: 9 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,12 @@ function isListedRemoteChat(bot) {
if (isHomeLevelRemote(remote)) return false;
return true;
}
function isShownRemoteChat(bot) {
if (!bot) return false;
if (isListedRemoteChat(bot)) return true;
if (bot.remote?.gone !== true) return false;
return bot.hidden !== true || bot.remote.hiddenByGone === true;
}
function listedChatBots(roster) {
return (roster?.bots ?? []).filter(isListedRemoteChat);
}
Expand Down Expand Up @@ -7489,15 +7495,15 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
const state = botState(running || away, doing, alert);
const shownAway = away ? awayGroups(groups, roster.roomsById) : [];
const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null;
if (!isListedRemoteChat(bot) && !active && !main) return null;
if (!isShownRemoteChat(bot) && !active && !main) return null;
const onContextMenu = (event) => {
event.preventDefault();
actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY });
};
const pinned = bot.pinned === true && !tile;
const remote = Boolean(bot.remote);
const shown = remote ? friendBotTitle(bot) : bot.name;
const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : "");
const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : "");
const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", ");
if (tile && wide) {
return (0, import_react13.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react13.createElement)(
Expand Down Expand Up @@ -7867,7 +7873,7 @@ function TeamSidebar(props) {
const entries2 = [
// Own Bots, friends' Bots, groups and face rooms share one list. A remote chat
// still uses BotRow's globe; gone / hidden / home-level remotes stay out.
...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })),
...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })),
...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })),
// The Agent sorts by its most recent Session, falling back to when it was added.
...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt }))
Expand Down
8 changes: 4 additions & 4 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { reducedMotion } from './activity-row.js'
import { Shimmer, workPhrase } from './cells.js'
import { springEasing, SPRING_SHAPE } from './motion.js'
import { hostText, t } from './i18n.js'
import { friendBotTitle, roomTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js'
import { friendBotTitle, roomTitle, sharedRoomTitle, isShownRemoteChat } from '../shared/remote-label.js'
import { showsDshRow } from './dsh-offer.js'
import { InboxRow } from './inbox.js'

Expand Down Expand Up @@ -60,7 +60,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
const state = botState(running || away, doing, alert)
const shownAway = away ? awayGroups(groups, roster.roomsById) : []
const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t('a group chat'), shownAway[0].tool, shownAway.length - 1) : null
if (!isListedRemoteChat(bot) && !active && !main) return null
if (!isShownRemoteChat(bot) && !active && !main) return null
const onContextMenu = (event) => {
event.preventDefault()
actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY })
Expand All @@ -70,7 +70,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
// A friend's Bot row (#384, #498): "Chief (SelfB1233)" on one line, as its header and the @ menu
// say it; the second line is the preview, or "Friend's Bot" before the first message.
const shown = remote ? friendBotTitle(bot) : bot.name
const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : '')
const secondLine = awayLine || preview || (remote ? (bot.remote?.gone ? t('no longer shared') : t("Friend's Bot")) : '')
const label = [shown, main ? t('Main Bot') : '', pinned ? t('Pinned') : '', running || away ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : '', unread ? t('Unread activity') : ''].filter(Boolean).join(', ')
if (tile && wide) {
return h('div', { className: 'bt-tile-wrap' }, h('button', {
Expand Down Expand Up @@ -350,7 +350,7 @@ export function TeamSidebar(props) {
const entries = [
// Own Bots, friends' Bots, groups and face rooms share one list. A remote chat
// still uses BotRow's globe; gone / hidden / home-level remotes stay out.
...roster.bots.filter(bot => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })),
...roster.bots.filter(bot => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })),
...roster.rooms.filter(room => !isPastFaceRoom(room)).map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })),
// The Agent sorts by its most recent Session, falling back to when it was added.
...(roster.agents ?? []).map(agent => ({ kind: 'agent', item: agent, at: Math.max(0, ...agent.sessions.map(id => stamps[id] ?? 0)) || agent.createdAt })),
Expand Down
24 changes: 13 additions & 11 deletions core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,9 @@ 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).
// were gone first. `remote.gone` alone says the Bot left; `hidden` stays the user's own flag
// (`roster.setFlags`) and is never written here (#956), so a chat that left keeps its row in the
// sidebar and a returning one cannot carry a hide of the user's away with it.
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()
Expand All @@ -256,17 +255,20 @@ export function install(rt) {
if (wasGone !== gone) {
bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) }
if (!gone) delete bot.remote.gone
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) {
// The Bot is published again: drop the marker an older version left with the hide it imposed.
if (!gone && bot.remote.hiddenByGone === true) {
delete bot.hidden
delete bot.remote.hiddenByGone
}
changed = true
} else if (gone && bot.hidden === true && bot.remote.hiddenByGone === true) {
// A state file written before #956 hid the row we imposed. The row is the conversation's, so
// one sync takes that hide back (#956); a hide the user set carries no marker and stays. The
// marker goes with it — taking the hide back is its only use, and while it stayed a hide the
// user set later read as ours, so the next sync deleted it (the row then ignored the Hide).
delete bot.hidden
delete bot.remote.hiddenByGone
changed = true
}
}
if (changed) await rt.save()
Expand Down
15 changes: 15 additions & 0 deletions core/src/shared/remote-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ export function isListedRemoteChat(bot) {
return true
}

// Two questions, two readers (#956). `isListedRemoteChat` answers "may I send to this Bot, offer it
// in a picker, mention it": a share that ended is not a place to send to. The sidebar asks "is this
// the user's conversation": a chat whose Bot left the published list keeps its row, its history and
// its way back, marked as no longer shared. Only a hide the user set (#611) takes a row out, and
// `remote.gone` never doubles as one — the two flags stay apart.
export function isShownRemoteChat(bot) {
if (!bot) return false
if (isListedRemoteChat(bot)) return true
if (bot.remote?.gone !== true) return false
// `hiddenByGone` is the marker a version before #956 wrote beside the hide it imposed: nothing here
// writes it, and `remoteChats.sync` deletes it with that hide, so a state file not yet synced still
// shows the row and a later hide of the user's is left alone.
return bot.hidden !== true || bot.remote.hiddenByGone === true
}

export function listedChatBots(roster) {
return (roster?.bots ?? []).filter(isListedRemoteChat)
}
Expand Down
3 changes: 2 additions & 1 deletion core/test/flybot-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line,
assert.doesNotMatch(row, /bt-row-owner/)
assert.doesNotMatch(styles, /\.bt-row-owner\{/)
// Before the first message the second line still says what the row is.
assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? t\("Friend's Bot"\) : ''\)/)
// #956: the placeholder names the state once the Bot is gone, so a kept row says why it is there.
assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? \(bot\.remote\?\.gone \? t\(.no longer shared.\) : t\("Friend's Bot"\)\) : ''\)/)
// The full name is on hover and in the label.
assert.match(row, /title: remote \? shown :/)
for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) {
Expand Down
29 changes: 27 additions & 2 deletions core/test/new-chat.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js'
import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js'
import { DICTIONARY } from '../src/client/i18n.js'

test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => {
Expand Down Expand Up @@ -56,7 +56,10 @@ test('the sidebar + menu wires plugin actions in after New group chat', () => {
const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8')
assert.match(slots, /isListedRemoteChat/)
const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8')
assert.match(sidebar, /isListedRemoteChat\(bot\)/)
// #956: the sidebar keeps a row for a chat we hid because the Bot left; every other reader
// (pickers, @ menu, the model's friend list) keeps the stricter isListedRemoteChat.
assert.match(sidebar, /isShownRemoteChat\(bot\)/)
assert.match(sidebar, /isShownRemoteChat\(bot\)\)\.map/)
assert.match(sidebar, /!isPastFaceRoom\(room\)/)
assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/)
// With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list.
Expand Down Expand Up @@ -198,3 +201,25 @@ test('#645: a line typed in the composer is the new Bot\'s first turn, not its i
assert.match(src, /onCreated: created/)
assert.match(card, /await onCreated\(bot\)/)
})

// #956: two hides, two flags. `remote.gone` says the Bot left the published list; `hidden` is only
// ever the user's own hide (#611). A chat we hid because it left keeps its row, marked; the user's
// hide wins over everything, and a returned chat is an ordinary row again.
test('#956: a row is kept for a share that ended, dropped for a hide the user set', () => {
const live = { id: 'live', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home' } }
const ended = { id: 'ended', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', gone: true } }
const legacy = { id: 'legacy', name: '旧', hidden: true, remote: { handle: '@old.friend.home', home: '@friend.home', gone: true, hiddenByGone: true } }
const userHidden = { id: 'user', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home' } }
const userHiddenWhileGone = { id: 'user2', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home', gone: true } }
assert.equal(isShownRemoteChat(live), true)
assert.equal(isShownRemoteChat(ended), true)
assert.equal(isShownRemoteChat(legacy), true)
assert.equal(isShownRemoteChat(userHidden), false)
assert.equal(isShownRemoteChat(userHiddenWhileGone), false)
assert.equal(isShownRemoteChat({ id: 'local', name: 'Chief' }), true)
assert.equal(isShownRemoteChat(undefined), false)
// The stricter reader is unchanged: a share that ended is still not a place to send to.
assert.equal(isListedRemoteChat(ended), false)
assert.equal(isListedRemoteChat(legacy), false)
assert.equal(isListedRemoteChat(live), true)
})
Loading
Loading