From fef653d82f1bd15c3eaa9ed1d8e32f988c9bd0d7 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:24:26 +0800 Subject: [PATCH] Clear the NEW divider once it was seen and the chat moved on (#275) --- changelog.d/275-new-divider-settles.fixed.md | 1 + core/client.js | 23 +- core/src/client/cells.js | 13 + core/src/client/header.js | 10 +- core/src/client/index.js | 8 +- core/test/turn-tail.test.js | 59 ++- docs/subsystems/core-client.md | 1 + patches/ds-bot/0003-flybot-api-client.patch | 110 ++++- patches/ds-bot/0008-client-bundle.patch | 477 +++++++++++-------- patches/ds-bot/0012-room-membership.patch | 34 +- 10 files changed, 536 insertions(+), 200 deletions(-) create mode 100644 changelog.d/275-new-divider-settles.fixed.md diff --git a/changelog.d/275-new-divider-settles.fixed.md b/changelog.d/275-new-divider-settles.fixed.md new file mode 100644 index 000000000..41173910a --- /dev/null +++ b/changelog.d/275-new-divider-settles.fixed.md @@ -0,0 +1 @@ +The **NEW** divider no longer sits in a chat for the whole visit. Once you have seen it and the chat moves on (a friend's next line, or your own message), it goes, the same as when you leave the chat and come back. This also covers a friend's typed line in one of your Bots' own chats ([#275](https://github.com/ThinkFlowLab/FlyBot/issues/275)). diff --git a/core/client.js b/core/client.js index d270bbc18..aebb63659 100644 --- a/core/client.js +++ b/core/client.js @@ -5691,6 +5691,15 @@ function openingTurn(turns, firstTurns, hiddenTurns) { return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { const turn = location2.turn; const time = location2.end.time; @@ -7230,7 +7239,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe (0, import_react11.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react11.createElement)(GoArrow)) ), (0, import_react11.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), - (0, import_react11.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), + (0, import_react11.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), (0, import_react11.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ); } @@ -7285,7 +7294,7 @@ function useTranscriptView() { }, []); return view; } -function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; @@ -7309,6 +7318,10 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { (0, import_react11.useEffect)(() => { if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); }, [view?.divider]); + const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); + (0, import_react11.useEffect)(() => { + if (above.seen && movedOn) actions?.settleVisit?.(sessionId); + }, [above.seen, movedOn, sessionId]); const fresh = release.base === null ? 0 : replies - release.base; const showDown = unpinned && fresh > 0 && !release.dismissed; const showUp = view?.divider === "above" && !above.seen && !above.dismissed; @@ -16330,6 +16343,12 @@ function apply(ctx) { const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, + // The NEW divider was seen and the chat moved on: it goes, as it would on leaving (#275). + settleVisit: (id) => ui2.set((state) => { + const visit = state.visits?.[id]; + const next = settledVisit(visit); + return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } }; + }), endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { diff --git a/core/src/client/cells.js b/core/src/client/cells.js index b34b8e4c3..d307c0c94 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -657,6 +657,19 @@ export function openingTurn(turns, firstTurns, hiddenTurns) { } // Began after the user last left the conversation and before this visit opened it. export const unseenSince = (visit, time) => typeof visit?.seen === 'number' && time > visit.seen && time <= visit.entered +// The chat moved on during this visit: a turn the chat shows began after the visit opened (a new +// line from a friend, the user's own message). Hidden exchange turns do not count (#275). +export function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== 'number') return false + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue + if (turns[key].first !== undefined && turns[key].first > visit.entered) return true + } + return false +} +// The unread divider has been on screen and the chat moved on, so nothing from before this visit +// is new any more: `seen` catches up with `entered` and every divider of the visit goes (#275). +export const settledVisit = visit => (typeof visit?.seen === 'number' && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit) // An entry in DSH's own turn tail, after what other plugins put there (the cards of // the files a turn delivered). The tail sits right before the next turn's first row, diff --git a/core/src/client/header.js b/core/src/client/header.js index c44996c42..86f0b8c84 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -4,7 +4,7 @@ import { currentPart, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' import { ActivityIndicator } from './activity-row.js' -import { clockLabel, openingTurn, unseenSince } from './cells.js' +import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' @@ -154,7 +154,7 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus h(MemoryNotice, { shown: memoryNotice }), h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))), h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''), - h(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), + h(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions })) } @@ -209,7 +209,7 @@ function useTranscriptView() { return view } -function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0 @@ -229,6 +229,10 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { const [above, setAbove] = useState({ seen: false, dismissed: false }) useEffect(() => { setRelease({ base: unpinned ? replies : null, dismissed: false }) }, [unpinned]) useEffect(() => { if (view?.divider === 'in-view') setAbove(state => (state.seen ? state : { ...state, seen: true })) }, [view?.divider]) + // Once the divider has been on screen, the next turn the chat shows (a friend's new line, the + // user's own message) settles the visit and the divider goes, in every chat (#275). + const movedOn = useTurnClock(state => movedOnSince(visit, state[sessionId], hiddenTurns)) + useEffect(() => { if (above.seen && movedOn) actions?.settleVisit?.(sessionId) }, [above.seen, movedOn, sessionId]) const fresh = release.base === null ? 0 : replies - release.base const showDown = unpinned && fresh > 0 && !release.dismissed const showUp = view?.divider === 'above' && !above.seen && !above.dismissed diff --git a/core/src/client/index.js b/core/src/client/index.js index 54116bb37..5e975981a 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -16,7 +16,7 @@ import { watchFaceMenu } from './face-at-filter.js' import { VOICE_BUNDLE } from './speech.js' import { sameVersion } from './dsh-offer.js' import { AnsweredCell, QuestionCard, QuestionDock } from './questions.js' -import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell } from './cells.js' +import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell, settledVisit } from './cells.js' import { Overlays } from './overlays.js' import { createBotBrowsers, serveBrowserReads } from './bot-browsers.js' import { BROWSER_CSS } from './browser-pane.js' @@ -528,6 +528,12 @@ export function apply(ctx) { const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null) ui.set(state => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })) }, + // The NEW divider was seen and the chat moved on: it goes, as it would on leaving (#275). + settleVisit: id => ui.set((state) => { + const visit = state.visits?.[id] + const next = settledVisit(visit) + return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } } + }), endVisit: (id) => { leaveVisits([id]) ui.set((state) => { diff --git a/core/test/turn-tail.test.js b/core/test/turn-tail.test.js index b22df9adf..d631cf2bb 100644 --- a/core/test/turn-tail.test.js +++ b/core/test/turn-tail.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' +import { readFileSync } from 'node:fs' import { build } from 'esbuild' const bundle = await build({ @@ -28,7 +29,7 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { AssistantCell, TurnTailCell } = module.exports +const { AssistantCell, TurnTailCell, TurnProcessCell, movedOnSince, settledVisit, unseenSince } = module.exports // Renders the named components of an element tree, leaving the rest as elements. const expand = (node, names) => { @@ -116,3 +117,59 @@ test('a reply to a line the friend typed themselves is drawn as the person, not assert.equal(find(expand(AssistantCell(other), ['PersonMessage', 'PersonMark', 'Bubbles']), node => node.props?.['data-person'] !== undefined).length, 0) assert.equal(find(expand(AssistantCell(props), ['PersonMessage', 'PersonMark', 'Bubbles']), node => node.props?.['data-person'] !== undefined).length, 0) }) + +// #275: a friend's line in my Bot's own chat came while I was away. The NEW divider marks it; once +// the divider has been on screen and the chat moves on (the friend's next line, my own message), +// the visit settles and every divider of the visit goes, as on leaving and coming back. +const dividers = node => find(expand(node, ['NewDivider']), item => item.props?.className === 'bt-new-sep').length +const tailOf = (clock, visit, turn = finished) => TurnTailCell({ + turn, sessionId: 'b', useTurnClock: select => select(clock), useUi: select => select({ visits: { b: visit } }), actions: { noteTurnTime() {} }, +}) +const processOf = (clock, visit, turn = 4) => TurnProcessCell({ + node: { location: { turn: { turn, start: { time: clock.b[turn].first } } }, data: { turn } }, + sessionId: 'b', useRoster: select => select(roster), useUi: select => select({ visits: { b: visit } }), useTurnClock: select => select(clock), actions: { noteTurnTime() {} }, +}) + +test('#275: a friend\'s line that came while away has the NEW divider; the settled visit has none', () => { + // Turn 3 ended before I left (1_500); the friend's line opened turn 4 at 3_000; I came back at 5_000. + const clock = { b: { 3: { first: 1_000, end: 2_000 }, 4: { first: 3_000, end: 3_500 } } } + const visit = { seen: 2_500, entered: 5_000 } + assert.equal(dividers(tailOf(clock, visit)), 1, 'divider above the line') + assert.equal(unseenSince(visit, 3_000), true) + const settled = settledVisit(visit) + assert.deepEqual([settled.seen, settled.entered], [5_000, 5_000]) + assert.equal(dividers(tailOf(clock, settled)), 0) + assert.equal(unseenSince(settled, 3_000), false) +}) + +test('#275: the reply to a line I saw begin, finished while away, loses its divider when the visit settles', () => { + const clock = { b: { 4: { first: 2_000, end: 3_000 } } } + const visit = { seen: 2_500, entered: 5_000 } + assert.equal(dividers(processOf(clock, visit)), 1) + assert.equal(dividers(processOf(clock, settledVisit(visit))), 0) +}) + +test('#275: only a shown turn that began after the visit opened moves the chat on', () => { + const visit = { seen: 2_500, entered: 5_000 } + assert.equal(movedOnSince(visit, { 3: { first: 1_000, end: 2_000 }, 4: { first: 3_000 } }), false, 'only earlier turns: not yet') + assert.equal(movedOnSince(visit, { 4: { first: 3_000 }, 5: { end: 6_000 } }), false, 'a turn with no start drawn yet') + assert.equal(movedOnSince(visit, { 4: { first: 3_000 }, 5: { first: 6_000 } }), true, 'the friend\'s next line') + assert.equal(movedOnSince(visit, { 5: { first: 6_000 } }, [5]), false, 'a hidden exchange turn is not something I saw') + assert.equal(movedOnSince(undefined, { 5: { first: 6_000 } }), false, 'no visit') +}) + +test('#275: a visit with nothing unread, or no record, is left as it is', () => { + const caught = { seen: 5_000, entered: 5_000 } + assert.equal(settledVisit(caught), caught) + const none = { seen: null, entered: 5_000 } + assert.equal(settledVisit(none), none) + assert.equal(settledVisit(undefined), undefined) +}) + +test('#275: the new-message pills settle the visit once the divider was seen and the chat moved on', () => { + const header = readFileSync(new URL('../src/client/header.js', import.meta.url), 'utf8') + assert.match(header, /movedOnSince\(visit, state\[sessionId\], hiddenTurns\)/) + assert.match(header, /if \(above\.seen && movedOn\) actions\?\.settleVisit\?\.\(sessionId\)/) + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(index, /settleVisit: id => ui\.set/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a682f0867..93c059b7a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -147,3 +147,4 @@ 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). 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 **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/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 3cc94d956..067455169 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -551,6 +551,26 @@ diff --git a/src/client/cells.js b/src/client/cells.js // The time separator: "Today 1:12 AM", "Yesterday 9:03 PM", "Thu, Sep 3 4:20 PM". const SEPARATOR_GAP_MS = 30 * 60 * 1000 export function clockLabel(time, now = Date.now()) { +@@ -444,6 +444,19 @@ export function openingTurn(turns, firstTurns, hiddenTurns) { + } + // Began after the user last left the conversation and before this visit opened it. + export const unseenSince = (visit, time) => typeof visit?.seen === 'number' && time > visit.seen && time <= visit.entered ++// The chat moved on during this visit: a turn the chat shows began after the visit opened (a new ++// line from a friend, the user's own message). Hidden exchange turns do not count (#275). ++export function movedOnSince(visit, turns, hiddenTurns) { ++ if (typeof visit?.entered !== 'number') return false ++ for (const key in turns ?? {}) { ++ if (hiddenTurns?.includes(Number(key))) continue ++ if (turns[key].first !== undefined && turns[key].first > visit.entered) return true ++ } ++ return false ++} ++// The unread divider has been on screen and the chat moved on, so nothing from before this visit ++// is new any more: `seen` catches up with `entered` and every divider of the visit goes (#275). ++export const settledVisit = visit => (typeof visit?.seen === 'number' && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit) + + // An entry in DSH's own turn tail, after what other plugins put there (the cards of + // the files a turn delivered). The tail sits right before the next turn's first row, @@ -540,7 +540,7 @@ function useLiveNote(actions, sessionId, id, entry) { }, [sessionId, id, key]) } @@ -776,7 +796,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { sameVersion } from './dsh-offer.js' import { AnsweredCell, QuestionCard, QuestionDock } from './questions.js' -import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell } from './cells.js' -+import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell } from './cells.js' ++import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell, settledVisit } from './cells.js' import { Overlays } from './overlays.js' import { createBotBrowsers, serveBrowserReads } from './bot-browsers.js' import { BROWSER_CSS } from './browser-pane.js' @@ -927,6 +947,19 @@ diff --git a/src/client/index.js b/src/client/index.js } writeMode(mode) surface.set(state => ({ ...state, mode })) +@@ -487,6 +487,12 @@ export function apply(ctx) { + const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null) + ui.set(state => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })) + }, ++ // The NEW divider was seen and the chat moved on: it goes, as it would on leaving (#275). ++ settleVisit: id => ui.set((state) => { ++ const visit = state.visits?.[id] ++ const next = settledVisit(visit) ++ return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } } ++ }), + endVisit: (id) => { + leaveVisits([id]) + ui.set((state) => { @@ -539,12 +539,14 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), @@ -2405,7 +2438,24 @@ diff --git a/test/mention-rule.test.js b/test/mention-rule.test.js diff --git a/test/turn-tail.test.js b/test/turn-tail.test.js --- a/test/turn-tail.test.js +++ b/test/turn-tail.test.js -@@ -101,3 +101,18 @@ test('the turn tail entry notes when the turn ended and shows the pause before t +@@ -2,6 +2,7 @@ import assert from 'node:assert/strict' + import { test } from 'node:test' + import { fileURLToPath } from 'node:url' + import { runInNewContext } from 'node:vm' ++import { readFileSync } from 'node:fs' + import { build } from 'esbuild' + + const bundle = await build({ +@@ -28,7 +28,7 @@ runInNewContext(bundle.outputFiles[0].text, { + return platform[name] + }, + }) +-const { AssistantCell, TurnTailCell } = module.exports ++const { AssistantCell, TurnTailCell, TurnProcessCell, movedOnSince, settledVisit, unseenSince } = module.exports + + // Renders the named components of an element tree, leaving the rest as elements. + const expand = (node, names) => { +@@ -101,3 +101,74 @@ test('the turn tail entry notes when the turn ended and shows the pause before t assert.deepEqual(noted, [['b', 3, 'end', 2_000]]) assert.equal(find(out, node => node.props?.className === 'bt-time-sep').length, 1) }) @@ -2424,6 +2474,62 @@ diff --git a/test/turn-tail.test.js b/test/turn-tail.test.js + assert.equal(find(expand(AssistantCell(other), ['PersonMessage', 'PersonMark', 'Bubbles']), node => node.props?.['data-person'] !== undefined).length, 0) + assert.equal(find(expand(AssistantCell(props), ['PersonMessage', 'PersonMark', 'Bubbles']), node => node.props?.['data-person'] !== undefined).length, 0) +}) ++ ++// #275: a friend's line in my Bot's own chat came while I was away. The NEW divider marks it; once ++// the divider has been on screen and the chat moves on (the friend's next line, my own message), ++// the visit settles and every divider of the visit goes, as on leaving and coming back. ++const dividers = node => find(expand(node, ['NewDivider']), item => item.props?.className === 'bt-new-sep').length ++const tailOf = (clock, visit, turn = finished) => TurnTailCell({ ++ turn, sessionId: 'b', useTurnClock: select => select(clock), useUi: select => select({ visits: { b: visit } }), actions: { noteTurnTime() {} }, ++}) ++const processOf = (clock, visit, turn = 4) => TurnProcessCell({ ++ node: { location: { turn: { turn, start: { time: clock.b[turn].first } } }, data: { turn } }, ++ sessionId: 'b', useRoster: select => select(roster), useUi: select => select({ visits: { b: visit } }), useTurnClock: select => select(clock), actions: { noteTurnTime() {} }, ++}) ++ ++test('#275: a friend\'s line that came while away has the NEW divider; the settled visit has none', () => { ++ // Turn 3 ended before I left (1_500); the friend's line opened turn 4 at 3_000; I came back at 5_000. ++ const clock = { b: { 3: { first: 1_000, end: 2_000 }, 4: { first: 3_000, end: 3_500 } } } ++ const visit = { seen: 2_500, entered: 5_000 } ++ assert.equal(dividers(tailOf(clock, visit)), 1, 'divider above the line') ++ assert.equal(unseenSince(visit, 3_000), true) ++ const settled = settledVisit(visit) ++ assert.deepEqual([settled.seen, settled.entered], [5_000, 5_000]) ++ assert.equal(dividers(tailOf(clock, settled)), 0) ++ assert.equal(unseenSince(settled, 3_000), false) ++}) ++ ++test('#275: the reply to a line I saw begin, finished while away, loses its divider when the visit settles', () => { ++ const clock = { b: { 4: { first: 2_000, end: 3_000 } } } ++ const visit = { seen: 2_500, entered: 5_000 } ++ assert.equal(dividers(processOf(clock, visit)), 1) ++ assert.equal(dividers(processOf(clock, settledVisit(visit))), 0) ++}) ++ ++test('#275: only a shown turn that began after the visit opened moves the chat on', () => { ++ const visit = { seen: 2_500, entered: 5_000 } ++ assert.equal(movedOnSince(visit, { 3: { first: 1_000, end: 2_000 }, 4: { first: 3_000 } }), false, 'only earlier turns: not yet') ++ assert.equal(movedOnSince(visit, { 4: { first: 3_000 }, 5: { end: 6_000 } }), false, 'a turn with no start drawn yet') ++ assert.equal(movedOnSince(visit, { 4: { first: 3_000 }, 5: { first: 6_000 } }), true, 'the friend\'s next line') ++ assert.equal(movedOnSince(visit, { 5: { first: 6_000 } }, [5]), false, 'a hidden exchange turn is not something I saw') ++ assert.equal(movedOnSince(undefined, { 5: { first: 6_000 } }), false, 'no visit') ++}) ++ ++test('#275: a visit with nothing unread, or no record, is left as it is', () => { ++ const caught = { seen: 5_000, entered: 5_000 } ++ assert.equal(settledVisit(caught), caught) ++ const none = { seen: null, entered: 5_000 } ++ assert.equal(settledVisit(none), none) ++ assert.equal(settledVisit(undefined), undefined) ++}) ++ ++test('#275: the new-message pills settle the visit once the divider was seen and the chat moved on', () => { ++ const header = readFileSync(new URL('../src/client/header.js', import.meta.url), 'utf8') ++ assert.match(header, /movedOnSince\(visit, state\[sessionId\], hiddenTurns\)/) ++ assert.match(header, /if \(above\.seen && movedOn\) actions\?\.settleVisit\?\.\(sessionId\)/) ++ const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') ++ assert.match(index, /settleVisit: id => ui\.set/) ++}) diff --git a/test/user-bubble.test.js b/test/user-bubble.test.js --- a/test/user-bubble.test.js +++ b/test/user-bubble.test.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 5657a4b1c..6b75c3b3e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -976,6 +976,14 @@ diff --git a/client.js b/client.js - const byId = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1009,23 +1017,29 @@ diff --git a/client.js b/client.js + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+} + } +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +- } +- return { ...state, [sessionId]: own }; +function PersonMark({ size = 22 }) { + return (0, import_react.createElement)( + "span", @@ -1037,10 +1051,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1086,15 +1183,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1127,13 +1219,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1145,8 +1231,7 @@ diff --git a/client.js b/client.js + people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people.length, 4), size); + const overflow = people.length > 4 ? people.length - 3 : 0; + const gap = size / 14; @@ -1165,97 +1250,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1658,11 +1661,35 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1583,50 +1583,216 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { + if (hidden && peer === void 0) continue; + turns.push({ turn: entry, peer, hidden }); } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { +- const at = turns.findIndex((entry) => entry.turn === turn); +- if (at === -1 || turns[at].peer === void 0) return null; +- const previous = turns[at - 1]; +- if (previous?.peer !== void 0 && previous.hidden) return "folded"; +- let count = 0; +- const peerIds = []; +- for (let index = at; index < turns.length; index += 1) { +- const entry = turns[index]; +- if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; +- count += entry.hidden ? 2 : 1; +- if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ const at = turns.findIndex((entry) => entry.turn === turn); ++ if (at === -1 || turns[at].peer === void 0) return null; ++ const previous = turns[at - 1]; ++ if (previous?.peer !== void 0 && previous.hidden) return "folded"; ++ let count = 0; ++ const peerIds = []; ++ for (let index = at; index < turns.length; index += 1) { ++ const entry = turns[index]; ++ if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; ++ count += entry.hidden ? 2 : 1; ++ if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ } ++ return { count, peerIds }; ++} +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; @@ -1671,22 +1698,33 @@ diff --git a/client.js b/client.js +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); +function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; - for (let index = outline.length - 1; index >= 0; index -= 1) { - const entry = outline[index]; - if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; ++ if (!Array.isArray(outline)) return ""; ++ for (let index = outline.length - 1; index >= 0; index -= 1) { ++ const entry = outline[index]; ++ if (hiddenTurns.includes(entry.turn)) continue; + const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text !== "") return stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1625,8 +1625,167 @@ function usageOf(list, ids) { - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); - } - ++ } ++ return ""; ++} ++var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); ++function usageOf(list, ids) { ++ let input = 0; ++ let cacheRead = 0; ++ let output = 0; ++ for (const id of ids) { ++ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; ++ if (!usage) continue; ++ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); ++ cacheRead += usage.cacheReadTokens ?? 0; ++ output += usage.outputTokens ?? 0; ++ } ++ if (input + output === 0) return ""; ++ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; ++ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++} ++ +// src/client/sources.js +function createSource(initial) { + let value = initial; @@ -1737,9 +1775,18 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return { count, peerIds }; + return { ...state, [sessionId]: own }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1763,7 +1810,8 @@ diff --git a/client.js b/client.js + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -+ } + } +- return ""; +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1786,7 +1834,18 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; -+} + } +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1834,7 +1893,10 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); -+ } + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -1843,9 +1905,9 @@ diff --git a/client.js b/client.js + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; -+} + } +var allThemes = () => ({ ...THEMES, ...customThemes }); -+ + // src/client/motion.js -var import_react = require("react"); +var import_react3 = require("react"); @@ -2263,6 +2325,14 @@ diff --git a/client.js b/client.js - badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null - ); +-} +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +function isGenericFaceTitle(name) { @@ -2290,17 +2360,24 @@ diff --git a/client.js b/client.js + seen.add(key); + const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); + rows.push({ owner, sort: handle || key }); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2311,8 +2388,24 @@ diff --git a/client.js b/client.js + seen.add(key); + out.push(owner); } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2336,19 +2429,7 @@ diff --git a/client.js b/client.js + if (people.length) bits.push(people.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2375,25 +2456,7 @@ diff --git a/client.js b/client.js + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; @@ -3959,7 +4022,19 @@ diff --git a/client.js b/client.js var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); -@@ -4236,7 +4236,7 @@ var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > vis +@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; + } + var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; ++function movedOnSince(visit, turns, hiddenTurns) { ++ if (typeof visit?.entered !== "number") return false; ++ for (const key in turns ?? {}) { ++ if (hiddenTurns?.includes(Number(key))) continue; ++ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; ++ } ++ return false; ++} ++var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { const turn = location2.turn; const time = location2.end.time; @@ -4086,8 +4161,7 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -4104,7 +4178,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -5357,7 +5432,7 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), - (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + (0, import_react11.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -+ (0, import_react11.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), ++ (0, import_react11.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react11.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ); } @@ -5369,7 +5444,16 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5658,18 +5658,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5642,7 +5642,7 @@ function useTranscriptView() { + }, []); + return view; + } +-function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ++function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { + const replies = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; +@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -5385,6 +5469,10 @@ diff --git a/client.js b/client.js + (0, import_react11.useEffect)(() => { if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); }, [view?.divider]); ++ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); ++ (0, import_react11.useEffect)(() => { ++ if (above.seen && movedOn) actions?.settleVisit?.(sessionId); ++ }, [above.seen, movedOn, sessionId]); const fresh = release.base === null ? 0 : replies - release.base; const showDown = unpinned && fresh > 0 && !release.dismissed; const showUp = view?.divider === "above" && !above.seen && !above.dismissed; @@ -8752,8 +8840,7 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react26.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: seats.length }) }, + seats.map((seat) => (0, import_react26.createElement)( + "div", @@ -8767,7 +8854,8 @@ diff --git a/client.js b/client.js + )) + ) : null, + face ? null : (0, import_react26.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react26.createElement)( + "div", @@ -12244,6 +12332,19 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); +@@ -13757,6 +13757,12 @@ function apply(ctx) { + const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); + ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); + }, ++ // The NEW divider was seen and the chat moved on: it goes, as it would on leaving (#275). ++ settleVisit: (id) => ui2.set((state) => { ++ const visit = state.visits?.[id]; ++ const next = settledVisit(visit); ++ return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } }; ++ }), + endVisit: (id) => { + leaveVisits([id]); + ui2.set((state) => { @@ -13819,12 +13819,14 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 983b335a9..fe979a0d6 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -424,9 +424,12 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js diff --git a/src/client/header.js b/src/client/header.js --- a/src/client/header.js +++ b/src/client/header.js -@@ -6,6 +6,7 @@ import { botState, BotAvatar, RoomAvatar } from './mark.js' +@@ -4,8 +4,9 @@ import { currentPart, latestMemoryChange } from './text.js' + import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' + import { botState, BotAvatar, RoomAvatar } from './mark.js' import { ActivityIndicator } from './activity-row.js' - import { clockLabel, openingTurn, unseenSince } from './cells.js' +-import { clockLabel, openingTurn, unseenSince } from './cells.js' ++import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' @@ -508,7 +511,7 @@ diff --git a/src/client/header.js b/src/client/header.js // Group chats have no voice mode, so the composer CSS needs to know the Session kind. useEffect(() => { if (room) document.body.dataset.btRoom = '' -@@ -97,7 +97,7 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -97,11 +97,11 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus }, bot ? h(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== undefined), badge: false }) : room ? h(RoomAvatar, { room, roster, size: 24 }) : null, @@ -517,6 +520,31 @@ diff --git a/src/client/header.js b/src/client/header.js h(MemoryNotice, { shown: memoryNotice }), h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))), h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''), +- h(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), ++ h(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions })) + } + +@@ -156,7 +156,7 @@ function useTranscriptView() { + return view + } + +-function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ++function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { + const replies = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline + return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0 +@@ -176,6 +176,10 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + const [above, setAbove] = useState({ seen: false, dismissed: false }) + useEffect(() => { setRelease({ base: unpinned ? replies : null, dismissed: false }) }, [unpinned]) + useEffect(() => { if (view?.divider === 'in-view') setAbove(state => (state.seen ? state : { ...state, seen: true })) }, [view?.divider]) ++ // Once the divider has been on screen, the next turn the chat shows (a friend's new line, the ++ // user's own message) settles the visit and the divider goes, in every chat (#275). ++ const movedOn = useTurnClock(state => movedOnSince(visit, state[sessionId], hiddenTurns)) ++ useEffect(() => { if (above.seen && movedOn) actions?.settleVisit?.(sessionId) }, [above.seen, movedOn, sessionId]) + const fresh = release.base === null ? 0 : replies - release.base + const showDown = unpinned && fresh > 0 && !release.dismissed + const showUp = view?.divider === 'above' && !above.seen && !above.dismissed diff --git a/src/client/sources.js b/src/client/sources.js --- a/src/client/sources.js +++ b/src/client/sources.js