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/3079-leave-visible-tail.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Leaving a chat no longer marks the whole Session read: the visit writes only the newest turn that was actually on screen, so messages that arrived below the fold, or after you had scrolled up, keep their **NEW** divider and the unread pill on the next visit. A chat that never reported a visible turn still advances to the moment you left, as before ([#3079](https://github.com/ThinkFlowLab/FlyBot/issues/3079)).
32 changes: 31 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7857,6 +7857,7 @@ function movedOnSince(visit, turns, hiddenTurns) {
return false;
}
var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit;
var leftSeen = (seen, tail, now = Date.now()) => Math.max(typeof seen === "number" ? seen : 0, typeof tail === "number" ? tail : now);
function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) {
const turn = location2.turn;
const time = location2.end.time;
Expand Down Expand Up @@ -12754,9 +12755,16 @@ function useTranscriptView() {
divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view";
}
const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152;
let tail;
for (const row of scroller.querySelectorAll("[data-chat-turn]")) {
const turn = Number(row.dataset.chatTurn);
const at = row.getBoundingClientRect();
if (Number.isFinite(turn) && at.bottom > box.top && at.top < box.bottom && (tail === void 0 || turn > tail)) tail = turn;
}
const next = {
unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
divider,
tail,
center: Math.round(box.left + box.width / 2),
top: Math.round(headerBottom + 8),
bottom: Math.round(window.innerHeight - box.bottom + composer + 8)
Expand Down Expand Up @@ -12793,6 +12801,14 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl
return count;
});
const view = useTranscriptView();
const tailAt = useTurnClock((state) => {
if (view?.tail === void 0) return null;
const entry = state[sessionId]?.[view.tail];
return entry?.end ?? entry?.first ?? null;
});
(0, import_react22.useEffect)(() => {
if (tailAt !== null) actions?.noteVisitTail?.(sessionId, tailAt);
}, [tailAt, sessionId]);
const unpinned = view?.unpinned === true;
const [release, setRelease] = (0, import_react22.useState)({ base: null, dismissed: false });
const [above, setAbove] = (0, import_react22.useState)({ seen: false, dismissed: false });
Expand Down Expand Up @@ -22881,9 +22897,16 @@ function apply(ctx) {
const setPlain = (plain) => surface.set((state) => state.plain === plain ? state : { ...state, plain });
const dropPanels = () => ui2.set((state) => state.details || state.renaming ? { ...state, details: null, view: "details", renaming: false, tab: null } : state);
const seenKey = (id) => roster.getSnapshot().byId?.[id]?.id ?? id;
const visitTails = {};
const leaveVisits = (ids) => {
const now = Date.now();
for (const id of ids) seenAt[seenKey(id)] = now;
const visits = ui2.getSnapshot().visits ?? {};
for (const id of ids) {
const key = seenKey(id);
const seen = Math.max(seenAt[key] ?? 0, visits[id]?.seen ?? 0);
seenAt[key] = leftSeen(seen, visitTails[key], now);
delete visitTails[key];
}
try {
localStorage.setItem(SEEN_KEY, JSON.stringify(seenAt));
} catch {
Expand Down Expand Up @@ -23406,6 +23429,13 @@ function apply(ctx) {
const next = settledVisit(visit);
return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } };
}),
// The newest turn this visit measured on screen (`useTranscriptView`); a leave writes only
// this, and only forward, so what was never on screen stays unread (#3079).
noteVisitTail: (id, tail) => {
if (typeof tail !== "number" || !Number.isFinite(tail)) return;
const key = seenKey(id);
if (!(visitTails[key] >= tail)) visitTails[key] = tail;
},
endVisit: (id) => {
leaveVisits([id]);
ui2.set((state) => {
Expand Down
7 changes: 7 additions & 0 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,13 @@ export function movedOnSince(visit, turns, hiddenTurns) {
// 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)
// What leaving a chat writes into the seen mark (#3079): the newest turn the view reported on
// screen, never the clock, so a turn that arrived below the fold or after the user scrolled up
// stays unseen and its NEW divider comes back. A Session that never reported a visible turn keeps
// the old fallback (`now`, a browser's first answer); the mark only moves forward, so an older
// tail cannot reopen turns that were already read.
export const leftSeen = (seen, tail, now = Date.now()) =>
Math.max(typeof seen === 'number' ? seen : 0, typeof tail === 'number' ? tail : now)

// 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,
Expand Down
18 changes: 18 additions & 0 deletions core/src/client/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,18 @@ function useTranscriptView() {
divider = at.bottom <= headerBottom ? 'above' : at.top >= box.bottom ? 'below' : 'in-view'
}
const composer = parseFloat(getComputedStyle(scroller).getPropertyValue('--dsh-composer-height')) || 152
// The newest turn whose row is on screen. Leaving writes only this, so a turn that arrived
// below the fold, or after the user scrolled up, stays unseen and its divider comes back (#3079).
let tail
for (const row of scroller.querySelectorAll('[data-chat-turn]')) {
const turn = Number(row.dataset.chatTurn)
const at = row.getBoundingClientRect()
if (Number.isFinite(turn) && at.bottom > box.top && at.top < box.bottom && (tail === undefined || turn > tail)) tail = turn
}
const next = {
unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
divider,
tail,
center: Math.round(box.left + box.width / 2),
top: Math.round(headerBottom + 8),
bottom: Math.round(window.innerHeight - box.bottom + composer + 8),
Expand Down Expand Up @@ -175,6 +184,15 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl
return count
})
const view = useTranscriptView()
// The newest turn on screen, resolved to the time the leave mark compares against (a running
// turn has no end yet, so its start counts). A Session with nothing rendered reports nothing,
// and the leave keeps the old fallback (#3079).
const tailAt = useTurnClock((state) => {
if (view?.tail === undefined) return null
const entry = state[sessionId]?.[view.tail]
return entry?.end ?? entry?.first ?? null
})
useEffect(() => { if (tailAt !== null) actions?.noteVisitTail?.(sessionId, tailAt) }, [tailAt, sessionId])
const unpinned = view?.unpinned === true
const [release, setRelease] = useState({ base: null, dismissed: false })
const [above, setAbove] = useState({ seen: false, dismissed: false })
Expand Down
23 changes: 21 additions & 2 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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, settledVisit } from './cells.js'
import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell, settledVisit, leftSeen } from './cells.js'
import { Overlays } from './overlays.js'
import { createBotBrowsers, serveBrowserReads } from './bot-browsers.js'
import { BROWSER_CSS } from './browser-pane.js'
Expand Down Expand Up @@ -122,9 +122,21 @@ export function apply(ctx) {
const dropPanels = () => ui.set(state => (state.details || state.renaming ? { ...state, details: null, view: 'details', renaming: false, tab: null } : state))
// A Bot's chat spans several parts, so when it was last left is kept per Bot.
const seenKey = id => roster.getSnapshot().byId?.[id]?.id ?? id
// Per Session, the newest turn this visit measured on screen (`noteVisitTail`). Leaving writes
// only that, so a turn that arrived below the fold or after the user scrolled up stays unseen
// and the NEW divider comes back for it (#3079).
const visitTails = {}
const leaveVisits = (ids) => {
const now = Date.now()
for (const id of ids) seenAt[seenKey(id)] = now
const visits = ui.getSnapshot().visits ?? {}
for (const id of ids) {
const key = seenKey(id)
// A settled visit already recorded what its divider said was seen (#275); the tail is the
// newest turn the view reported. Neither may move the mark backwards.
const seen = Math.max(seenAt[key] ?? 0, visits[id]?.seen ?? 0)
seenAt[key] = leftSeen(seen, visitTails[key], now)
delete visitTails[key]
}
try { localStorage.setItem(SEEN_KEY, JSON.stringify(seenAt)) } catch { /* storage unavailable */ }
}
ctx.effect(() => {
Expand Down Expand Up @@ -587,6 +599,13 @@ export function apply(ctx) {
const next = settledVisit(visit)
return next === visit ? state : { ...state, visits: { ...state.visits, [id]: next } }
}),
// The newest turn this visit measured on screen (`useTranscriptView`); a leave writes only
// this, and only forward, so what was never on screen stays unread (#3079).
noteVisitTail: (id, tail) => {
if (typeof tail !== 'number' || !Number.isFinite(tail)) return
const key = seenKey(id)
if (!(visitTails[key] >= tail)) visitTails[key] = tail
},
endVisit: (id) => {
leaveVisits([id])
ui.set((state) => {
Expand Down
92 changes: 92 additions & 0 deletions core/test/visit-tail.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Leaving a chat used to write the whole Session read up to *now* (`leaveVisits` in
// `index.js` did `seenAt[seenKey(id)] = Date.now()`), including turns that had never been
// scrolled into view, so the NEW divider never came back for them. A leave, a `pagehide` or
// a close now writes only the newest turn the view actually reported on screen, falls back
// to `now` only for a Session that never reported one (a browser's first answer, an empty
// chat), and never moves the mark backwards (#3079).
//
// The decision is `leftSeen` in `cells.js`, next to `movedOnSince`/`settledVisit`, the other
// turn-time rule the divider and the leave path share; the wiring is pinned in `header.js`
// (which measures the visible tail) and `index.js` (which writes it).
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({
entryPoints: [fileURLToPath(new URL('../src/client/cells.js', import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
function MarkdownText() { return null }
const module = { exports: {} }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports,
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [initial, () => {}], useEffect: () => {}, useRef: current => ({ current }),
useLayoutEffect: () => {}, useMemo: make => make(), Fragment: 'fragment',
},
'react-dom': { createPortal: node => node },
'@deepseek-ai/dsh-client-ui-primitives': { MarkdownText },
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
})
const { leftSeen } = module.exports

const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
const header = readFileSync(new URL('../src/client/header.js', import.meta.url), 'utf8')
/** Non-comment source lines only, so a comment cannot satisfy a pin. */
const code = src => src.split('\n').filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line)).join('\n')

// t0 is the turn that was on screen when the chat was last left; t1 a turn that arrived below
// the viewport, or after the user had scrolled up, so it was never drawn on screen.
const T0 = 2_500
const T1 = 3_000
const NOW = 9_000

test('#3079: a leave keeps the mark at the newest turn that was on screen, never the clock', () => {
assert.equal(leftSeen(T0, T0, NOW), T0, 'the turn below the fold stays unread')
})

test('#3079: a turn seen in the viewport and settled still advances the mark when the chat is left', () => {
assert.equal(leftSeen(T0, T1, NOW), T1)
assert.ok(leftSeen(T0, T1, NOW) > T0, 'the settled turn does not lose what the divider said was seen')
})

test('#3079: a chat that never reported a visible turn still advances to now, so a leave always marks something', () => {
assert.equal(leftSeen(T0, undefined, NOW), NOW, 'no visible tail: the old fallback')
assert.equal(leftSeen(undefined, undefined, NOW), NOW, 'a browser with no record yet')
})

test('#3079: the mark only moves forward: an older tail cannot reopen turns that were already read', () => {
assert.equal(leftSeen(NOW, T0, NOW + 1_000), NOW)
assert.equal(leftSeen(T1, T0, T1), T1)
})

test('#3079: index.js records the newest visible tail per Session and writes it on a leave', () => {
assert.match(code(index), /const visitTails = \{\}/, 'the per-Session visible tails')
assert.match(code(index), /noteVisitTail: \(id, tail\) =>/, 'the view reports the tail through an action')
assert.match(code(index), /seenAt\[key\] = leftSeen\(/, 'leaveVisits writes the rule, not the clock')
assert.doesNotMatch(code(index), /seenAt\[seenKey\(id\)\] = now/, 'the clock write is gone')
})

test('#3079: every way out of a chat (close, page hide) goes through the same leave path', () => {
assert.match(code(index), /endVisit: \(id\) => \{\s*leaveVisits\(\[id\]\)/, 'a close leaves through leaveVisits')
assert.match(code(index), /onPageHide = \(\) => leaveVisits\(Object\.keys\(ui\.getSnapshot\(\)\.visits \?\? \{\}\)\)/, 'a page hide does too')
assert.match(code(index), /const seen = Math\.max\(seenAt\[key\] \?\? 0, visits\[id\]\?\.seen \?\? 0\)/, 'a settled visit keeps what it saw')
})

test('#3079: the view measures the newest turn on screen and reports it, not the unread count', () => {
const view = code(header).slice(code(header).indexOf('function useTranscriptView()'), code(header).indexOf('function NewMessagePills('))
assert.match(view, /querySelectorAll\('\[data-chat-turn\]'\)/, 'the rendered turn rows')
assert.match(view, /tail/, 'the view carries the newest visible turn')
assert.match(view, /at\.bottom > box\.top && at\.top < box\.bottom/, 'only a row inside the viewport counts')
assert.match(code(header), /actions\?\.noteVisitTail\?\.\(sessionId, /, 'the pills report it for their Session')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ Settings → Plugins draws a switch only for a plugin this profile actually load
- 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`.
- 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`. What a leave writes is not the clock (#3079): `useTranscriptView` reports the newest turn whose row is inside the viewport (`[data-chat-turn]`, its `end`, or its `first` while it still runs) through `actions.noteVisitTail`, and `leaveVisits` stores only that (`leftSeen`, never backwards) for a close and for `pagehide`. A Session that never reported a visible tail keeps the old fallback (`now`, which is also a browser's first answer), so a leave always marks something; a turn that arrived below the fold, or after the user scrolled up, keeps `first > seen` and its divider comes back on the next visit. Covered by `core/test/visit-tail.test.js`.

### Project board pane (#1710, reachable since #1701)

Expand Down
Loading
Loading