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/497-live-friend-line.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {});
Expand Down
3 changes: 2 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {})
Expand Down
7 changes: 7 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
38 changes: 38 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Bot>`) 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`): `<person or friend's Bot> is talking to <Bot> 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`.
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<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).
- `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.
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions patches/ds-bot/0005-social-seams.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading