diff --git a/changelog.d/497-live-friend-line.fixed.md b/changelog.d/497-live-friend-line.fixed.md new file mode 100644 index 000000000..33e5d21d6 --- /dev/null +++ b/changelog.d/497-live-friend-line.fixed.md @@ -0,0 +1 @@ +A friend's message to one of your Bots, the reply waiting for your approval, the Inbox count and the unread badge now show up in an open FlyBot window within a few seconds, instead of up to 15 seconds later, which looked like it needed a reload ([#497](https://github.com/ThinkFlowLab/FlyBot/issues/497)). diff --git a/core/client.js b/core/client.js index a5051ae9d..8e2e0d614 100644 --- a/core/client.js +++ b/core/client.js @@ -16843,7 +16843,7 @@ function apply(ctx) { const pull = async () => { const order = ++pulls; const known = roster.getSnapshot(); - const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language() }); + const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language(), watching: !document.hidden }); if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); diff --git a/core/src/client/index.js b/core/src/client/index.js index 13496f79c..2eceaeaff 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -163,7 +163,8 @@ export function apply(ctx) { const pull = async () => { const order = ++pulls const known = roster.getSnapshot() - const value = await call('state', { since: known.revision, models: known.modelsKey ?? '', locale: language() }) + // `watching`: this window is on screen, so the host reads friends' inboxes at the live cadence (#497). + const value = await call('state', { since: known.revision, models: known.modelsKey ?? '', locale: language(), watching: !document.hidden }) if (order < applied) return applied = order const nextActivity = JSON.stringify(value.activity ?? {}) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 5c34ffe26..9589ef6bf 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -24,6 +24,12 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale + // When a window on screen last read the team (#497). A visible window asks every 1.5 s, a hidden one + // every 10 s and says so (`watching: false`); a plugin that polls a remote inbox reads it faster while + // someone watches (flybot.watched), so a friend's line and its held reply reach the open window live. + const WATCH_MS = 8_000 + let watchedAt = 0 + rt.watched = () => Date.now() - watchedAt < WATCH_MS rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) const ok = value => ({ ok: true, value }) @@ -188,6 +194,7 @@ export function install(rt) { switch (endpoint) { case 'state': if (typeof body.locale === 'string' && /^[a-z]{2,3}(-[\w-]+)?$/i.test(body.locale)) uiLocale = body.locale + if (body.watching !== false) watchedAt = Date.now() if (readOnly === undefined) await rt.bootstrap() await rt.catalog() await rt.loadSecrets() diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 1dcb88145..00ada3909 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -960,6 +960,9 @@ export function install(rt) { fields, // True while another dsh holds the team: plugins then only read (no polling, no writes). readOnly: () => rt.readOnly() !== undefined, + // True while a FlyBot window on screen reads the team (#497): a plugin that polls a remote inbox + // reads it at its live cadence then, so what arrives shows in that window within seconds. + watched: () => rt.watched?.() === true, // Resolves once ds-bot's saved team is loaded, so bot.list() has every Bot. ready: () => rt.load().then(() => undefined), on: onFor(plugin), diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index a6b8320a6..197aef3c6 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -289,6 +289,44 @@ test("#420: a friend's line stamps the receiving Bot for its unread mark, and on assert.ok((await call('state', {})).value.bots.find(bot => bot.id === 'a').friendLineAt >= stamp) }) +test("#497: a friend's first line and its held reply reach a window that keeps polling, with no reload", async (t) => { + const { fb, call, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@bob.bot') + // The window is open: it knows the team at this revision and polls with `since`, as the client does. + const open = (await call('state', {})).value + const poll = known => call('state', { since: known.revision, models: known.modelsKey }) + assert.equal((await poll(open)).value.unchanged, true, 'nothing new yet') + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob (typed via @bob.bot, Bot Social)', typedBy: 'Bob' }, to: 'a', text: 'hi from B' }) + const line = (await poll(open)).value + assert.notEqual(line.unchanged, true, "the friend's line moves the team, so the open window reads it") + assert.ok(Number.isFinite(line.bots.find(bot => bot.id === 'a').friendLineAt), "the Chief row gets its friend's-line stamp (unread badge)") + // The Bot's answer is held for its owner (the social plugin's hold): the card and the count arrive the same way. + fb.bot.send.use({ name: 'hold-all', handle: () => ({ action: 'hold', reason: 'messages to friends need your approval' }) }) + const held = await fb.bot.send({ from: 'a', to: { kind: 'remote', handle: '@bob.bot' }, text: 'Hey B!', role: 'reply', via: 'auto-reply', meta: { replyIn: { sessionId: 'a', turn: 2 } } }) + assert.equal(held.action, 'hold') + const card = (await poll(line)).value + assert.notEqual(card.unchanged, true, 'the hold moves the team as well') + const chief = card.bots.find(bot => bot.id === 'a') + assert.equal(chief.pendingCount, 1, 'the Inbox and the row count the waiting reply') + assert.deepEqual(chief.heldReplies.map(reply => [reply.status, reply.sessionId, reply.turn, reply.to]), [['pending', 'a', 2, '@bob.bot']], 'the Waiting for approval card is drawn on the turn it answers') +}) + +test('#497: flybot.watched says whether a window on screen reads the team', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + assert.equal(fb.watched(), false, 'no window yet') + await call('state', { watching: false }) + assert.equal(fb.watched(), false, 'a hidden window does not count') + await call('state', { watching: true }) + assert.equal(fb.watched(), true) + const real = Date.now + Date.now = () => real() + 9_000 + try { assert.equal(fb.watched(), false, 'a window that stopped asking is gone after a few seconds') } finally { Date.now = real } + await call('state', {}) + assert.equal(fb.watched(), true, 'an older client that does not say counts as on screen') +}) + test('#226: chatApprovals.changed moves the team revision so a friend chat badge refreshes', async (t) => { const { fb, call, stop } = await start() t.after(stop) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093ac..64b244f65 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -172,4 +172,5 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( - A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: `AssistantCell` looks the turn up in `roster.remoteTypedTurns` (`typedBy` in `src/client/text.js`) and renders `PersonMessage` — the person's name and a person mark (`.bt-person-mark`) — instead of the Bot's `Bubbles`. The name comes from the roster (the authenticated Bot Social `from`), never from message text. - The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). In a face-to-face room `remoteSpeaker(source, roster, sessionId)` draws the Bot with its room seat's look (the participant's `color` / `avatar` from Bot Social `members[]`), so the bubble matches the Members panel, the room avatar and the waiting tiles; a chat record for that handle keeps its id and wears the seat's look (#182). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276). - A friend's line in my Bot's own chat (#420): `friend-lines.js` `friendLineNews` compares each Bot's host stamp `friendLineAt` with the last stamp this browser has shown (`localStorage` `ds-bot:friend-lines`; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited, `index.js` marks the Bot unread the way **Mark as Unread** does, so the row shows the blue dot until the chat is opened. A browser with no record yet takes what is there as shown; another member's shared Bot and a friend's Bot chat are skipped. The Main Bot tile shows the same dot (`.bt-tile-unread`; a wait still shows on its avatar). A line whose source has `remote.firstContact` opens with one muted `FirstContact` line (`cells.js`): ` is talking to through Bot Social`. Covered by `core/test/friend-lines.test.js` and `core/test/inbound-ui.test.js`. +- The roster poll (`index.js` `pull`) sends `watching: !document.hidden` with each `state` call (#497), so the host knows a window is on screen and the social plugin reads friends' inboxes at its live cadence: a friend's first line, its held reply's card, the Inbox count and the unread badge arrive in that window within seconds, with no reload. - The **NEW** divider (`cells.js` `TurnTailCell` / `TurnProcessCell` / the unread opening) compares turn times with the visit's `seen` (when the chat was last left) and `entered` (when this visit opened). Once the divider has been on screen (`NewMessagePills` in `header.js`, `view.divider === 'in-view'`) and a shown turn begins after the visit opened (`movedOnSince`: a friend's next line, the user's own message; hidden exchange turns do not count), `actions.settleVisit` sets `seen` to `entered` (`settledVisit`) and every divider of the visit goes, as on leaving and coming back. A divider still scrolled out of view, or a chat where nothing new happened, keeps it until the visit ends. Same rule in every chat, including a friend's typed line in the Bot's own chat (#275). Covered by `core/test/turn-tail.test.js`. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index da72f545a..2f29e0bae 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -158,6 +158,7 @@ credentials and team mode remain separate work under #55/#198. - Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode. - Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too. +- 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). - `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. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index e4bd37ca8..c9cbd2e84 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -64,6 +64,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Chat-row pending badge - `flybot.chatApprovals.setProvider` attaches pending Bot Social holds to a 1:1 friend's-Bot chat the same way `roomApprovals` does for groups, and to a local Bot for its own held sends (#371). The team view puts `pendingCount` on that bot; the sidebar paints the gold wait dot. `flybot.chatApprovals.changed()` tells the host the list changed (a hold arrived or was decided), so the badge refreshes at once instead of on the next unrelated revision (#226). Still not team mode. +- `flybot.watched()` is true while a FlyBot window on screen reads the team (its roster poll, within the last 8 s; a hidden window does not count, #497). A plugin that polls a remote inbox reads it at its live cadence then, so what arrives reaches the open window within seconds; with no window it keeps its idle cadence. It only observes the client polls and changes nothing. ### Sidebar + friend actions diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda1..3296e7529 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -170,7 +170,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. - **Open** on a Past room calls `social/face-open` (`plugins/social/src/social.js`): it returns the existing local hosted session or recreates one from the saved room, so a peer home can open a room that already left Bot Social (#171). Titles come from `plugins/social/src/face-title.js`, with participant owners in stable handle order, so both homes show the same name (#172). -- Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. The social strip still refreshes past rooms about every 5s. +- Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). While a FlyBot window is on screen (`flybot.watched()`, #497) a second timer also reads the inbox at the `facePollMs` cadence, so a friend's first line, the held reply's **Waiting for approval** card, the Inbox count and the row's unread badge reach the open window within a few seconds instead of up to 15 s later (the self-test's "only after a reload"). It skips a tick the shared timer already covers (a live face room) or that follows an inbox read by less than half its period, and it reads nothing with no window open, so Bot Social sees the faster reads only while someone watches. The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. The social strip still refreshes past rooms about every 5s. - Sidebar **+** holds Face-to-face room, Add friend, Accept invite, one-click Copy invite link (right-click a Bot), and Copy my handle. Friend requests is not a duplicate + row. Per-friend Auto-send is on that Bot's settings; face-room Auto-send is on the open room overview — both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`. ### Face-to-face two-instance rooms (#117) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e02c40edd..b08a847f5 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1109,6 +1109,16 @@ diff --git a/src/client/index.js b/src/client/index.js const refreshed = new Set() // Pulls can overlap (the poll and a navigation); an older answer never wins. let pulls = 0 +@@ -132,7 +132,8 @@ export function apply(ctx) { + const pull = async () => { + const order = ++pulls + const known = roster.getSnapshot() +- const value = await call('state', { since: known.revision, models: known.modelsKey ?? '', locale: language() }) ++ // `watching`: this window is on screen, so the host reads friends' inboxes at the live cadence (#497). ++ const value = await call('state', { since: known.revision, models: known.modelsKey ?? '', locale: language(), watching: !document.hidden }) + if (order < applied) return + applied = order + const nextActivity = JSON.stringify(value.activity ?? {}) @@ -145,11 +145,14 @@ export function apply(ctx) { const live = {} if (value.connectors !== undefined && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live.connectors = value.connectors diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 6bf22bb95..0572ad39d 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -231,11 +231,17 @@ diff --git a/src/host/channel.js b/src/host/channel.js const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } -@@ -21,14 +21,15 @@ export function install(rt) { +@@ -21,14 +21,21 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale - rt.uiLanguage = () => (uiLocale === undefined ? undefined : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) ++ // When a window on screen last read the team (#497). A visible window asks every 1.5 s, a hidden one ++ // every 10 s and says so (`watching: false`); a plugin that polls a remote inbox reads it faster while ++ // someone watches (flybot.watched), so a friend's line and its held reply reach the open window live. ++ const WATCH_MS = 8_000 ++ let watchedAt = 0 ++ rt.watched = () => Date.now() - watchedAt < WATCH_MS + rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) const ok = value => ({ ok: true, value }) @@ -379,7 +385,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,43 @@ export function install(rt) { +@@ -97,28 +97,44 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -409,6 +415,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js switch (endpoint) { case 'state': if (typeof body.locale === 'string' && /^[a-z]{2,3}(-[\w-]+)?$/i.test(body.locale)) uiLocale = body.locale ++ if (body.watching !== false) watchedAt = Date.now() if (readOnly === undefined) await rt.bootstrap() await rt.catalog() await rt.loadSecrets() diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f834163..9f378b000 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -13462,7 +13462,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13344,6 +13344,18 @@ function apply(ctx) { +@@ -13344,13 +13344,25 @@ function apply(ctx) { } return { ...state, unread }; }); @@ -13481,6 +13481,14 @@ diff --git a/client.js b/client.js const refreshed = /* @__PURE__ */ new Set(); let pulls = 0; let applied = 0; + const pull = async () => { + const order = ++pulls; + const known = roster.getSnapshot(); +- const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language() }); ++ const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language(), watching: !document.hidden }); + if (order < applied) return; + applied = order; + const nextActivity = JSON.stringify(value.activity ?? {}); @@ -13368,10 +13368,15 @@ function apply(ctx) { const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 1dcb88145..00ada3909 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -960,6 +960,9 @@ export function install(rt) { fields, // True while another dsh holds the team: plugins then only read (no polling, no writes). readOnly: () => rt.readOnly() !== undefined, + // True while a FlyBot window on screen reads the team (#497): a plugin that polls a remote inbox + // reads it at its live cadence then, so what arrives shows in that window within seconds. + watched: () => rt.watched?.() === true, // Resolves once ds-bot's saved team is loaded, so bot.list() has every Bot. ready: () => rt.load().then(() => undefined), on: onFor(plugin), diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index a6b8320a6..197aef3c6 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -289,6 +289,44 @@ test("#420: a friend's line stamps the receiving Bot for its unread mark, and on assert.ok((await call('state', {})).value.bots.find(bot => bot.id === 'a').friendLineAt >= stamp) }) +test("#497: a friend's first line and its held reply reach a window that keeps polling, with no reload", async (t) => { + const { fb, call, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@bob.bot') + // The window is open: it knows the team at this revision and polls with `since`, as the client does. + const open = (await call('state', {})).value + const poll = known => call('state', { since: known.revision, models: known.modelsKey }) + assert.equal((await poll(open)).value.unchanged, true, 'nothing new yet') + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob (typed via @bob.bot, Bot Social)', typedBy: 'Bob' }, to: 'a', text: 'hi from B' }) + const line = (await poll(open)).value + assert.notEqual(line.unchanged, true, "the friend's line moves the team, so the open window reads it") + assert.ok(Number.isFinite(line.bots.find(bot => bot.id === 'a').friendLineAt), "the Chief row gets its friend's-line stamp (unread badge)") + // The Bot's answer is held for its owner (the social plugin's hold): the card and the count arrive the same way. + fb.bot.send.use({ name: 'hold-all', handle: () => ({ action: 'hold', reason: 'messages to friends need your approval' }) }) + const held = await fb.bot.send({ from: 'a', to: { kind: 'remote', handle: '@bob.bot' }, text: 'Hey B!', role: 'reply', via: 'auto-reply', meta: { replyIn: { sessionId: 'a', turn: 2 } } }) + assert.equal(held.action, 'hold') + const card = (await poll(line)).value + assert.notEqual(card.unchanged, true, 'the hold moves the team as well') + const chief = card.bots.find(bot => bot.id === 'a') + assert.equal(chief.pendingCount, 1, 'the Inbox and the row count the waiting reply') + assert.deepEqual(chief.heldReplies.map(reply => [reply.status, reply.sessionId, reply.turn, reply.to]), [['pending', 'a', 2, '@bob.bot']], 'the Waiting for approval card is drawn on the turn it answers') +}) + +test('#497: flybot.watched says whether a window on screen reads the team', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + assert.equal(fb.watched(), false, 'no window yet') + await call('state', { watching: false }) + assert.equal(fb.watched(), false, 'a hidden window does not count') + await call('state', { watching: true }) + assert.equal(fb.watched(), true) + const real = Date.now + Date.now = () => real() + 9_000 + try { assert.equal(fb.watched(), false, 'a window that stopped asking is gone after a few seconds') } finally { Date.now = real } + await call('state', {}) + assert.equal(fb.watched(), true, 'an older client that does not say counts as on screen') +}) + test('#226: chatApprovals.changed moves the team revision so a friend chat badge refreshes', async (t) => { const { fb, call, stop } = await start() t.after(stop) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 38977766e..1fc055099 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1737,6 +1737,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const live = config.facePollMs > 0 ? config.facePollMs : 4_000 return hasLiveFace() ? Math.min(live, idle) : idle } + // When the inbox was last asked for, so the watched poll below does not double the shared timer's. + let inboxAt = 0 + const pollInboxNow = () => { inboxAt = now(); return pollInbox() } let pollTimer let lastPollMs function armSocialPoll() { @@ -1746,13 +1749,25 @@ export function createSocial(service, config = {}, { client: given, api: givenAp lastPollMs = ms if (!(ms > 0)) return pollTimer = every(() => { - void pollInbox().catch(() => {}) + void pollInboxNow().catch(() => {}) void pollFaceRooms().then(() => armSocialPoll()).catch(() => armSocialPoll()) if (now() - fetchedAt >= REMOTE_TTL_MS) void remoteBots({ fresh: true }).catch(() => {}) }, ms) } disposers.push(() => { if (pollTimer) clear(pollTimer) }) armSocialPoll() + // While a FlyBot window is on screen (#497, `flybot.watched`), the inbox is also read at the live + // cadence: a friend's first line, the held reply's approval card and the Inbox count reach the open + // window within seconds, not up to one idle poll (15 s) later. With no window open only the idle poll + // runs, and a tick that the shared timer already covers (a live face room) is skipped. + const watchMs = config.facePollMs > 0 ? config.facePollMs : 4_000 + if (config.inboxPollMs > watchMs) { + const watchTimer = every(() => { + if (flybot.watched?.() !== true || lastPollMs <= watchMs || now() - inboxAt < watchMs / 2) return + void pollInboxNow().catch(() => {}) + }, watchMs) + disposers.push(() => clear(watchTimer)) + } // ------------------------------------------------------------ model tools (every local Bot) disposers.push(flybot.tools.register({ diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62b..b76386aa2 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -339,6 +339,43 @@ test('inbound messages reach the addressed member Bot as untrusted text', async assert.equal(received.get('a'), undefined) }) +test("#497: while a FlyBot window is on screen the inbox is read at the live cadence, so a friend's first line reaches it in seconds", async (t) => { + const { ctx, call, received, stop } = await team() + t.after(stop) + const server = mockServer() + const timers = [] + let clock = 1_000_000 + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 15_000, facePollMs: 4_000 }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' }, + now: () => clock, + setInterval: (fn, ms) => { const timer = { fn, ms }; timers.push(timer); return timer }, + clearInterval: (timer) => { const at = timers.indexOf(timer); if (at >= 0) timers.splice(at, 1) }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + assert.ok(timers.some(timer => timer.ms === 15_000), 'the idle poll stays') + const live = timers.filter(timer => timer.ms === 4_000) + assert.equal(live.length, 1, 'one live-cadence inbox timer beside it') + const reads = () => server.calls.filter(entry => entry.name === 'list_inbox').length + const tick = async () => { clock += 4_000; live[0].fn(); await settle() } + const idle = reads() + await tick() + assert.equal(reads(), idle, 'no window on screen: the live timer reads nothing') + // A window on screen reads the team (the client's roster poll), and B's first line is waiting on Bot Social. + await call('state', { watching: true }) + server.events.set('m1', [{ event_id: 'e497', event_type: 'message.received', conversation_id: 'c497', typed_by_owner: true, from: { handle: '@coder.bob.home', home_handle: '@bob.home', display_name: 'Coder', owner_display_name: 'Bob' }, untrusted_content: '<<>>\n{"text":"hi from B","structured":null}\n<<>>' }]) + await tick() + await waitUntil(() => received.get('a')?.length === 1) + assert.match(received.get('a')[0], /hi from B/, 'delivered on the first live tick, not up to 15 s later') + const watched = reads() + // The window went away (hidden, closed): the live timer stops reading. + const real = Date.now + Date.now = () => real() + 9_000 + try { await tick() } finally { Date.now = real } + assert.equal(reads(), watched) +}) + test('friends\' remote Bots are chat members: in the roster, @-mentioned through social approval, and shown as their own bubbles', async (t) => { const { ctx, call, received, stop } = await team() t.after(stop)