From ae42bd5b15c57ecb5445c69b74e5e15f27d89e3d Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:43:29 +0800 Subject: [PATCH] fix(client): keep the landing on a session the list knows (#2206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing runs from a 400 ms timer with no click behind it, and the shell's replaceMain calls sessions.retain, which throws synchronously on an id its session list does not know (`sessions.retain: unknown session`). landingPart answered with the roster alone, so an id the list had not listed yet — a cold load, an archived part the roster still named as current — was handed to openSession and became the two uncaught errors on load. With the list in hand only a destination the list knows is returned, in the same priority as before: a fresh part it has not caught up with falls back to the chat it replaced, and with nothing known landingPart answers undefined and landOn records `refused` and leaves the view where it is, instead of clearing the surface for a navigation that will not happen. A caller that passes no list keeps the id fallback. Fixes #2206 --- .../2206-landing-part-unknown.fixed.md | 1 + core/client.js | 15 +++++++++----- core/src/client/index.js | 5 ++++- core/src/client/text.js | 18 +++++++++++++---- core/test/land-on.test.js | 15 ++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 20 ++++++++++++++----- patches/ds-bot/0003-flybot-api-client.patch | 7 +++++-- patches/ds-bot/0008-client-bundle.patch | 19 +++++++++++------- patches/ds-bot/files/test/land-on.test.js | 15 ++++++++++++++ 10 files changed, 92 insertions(+), 25 deletions(-) create mode 100644 changelog.d/2206-landing-part-unknown.fixed.md diff --git a/changelog.d/2206-landing-part-unknown.fixed.md b/changelog.d/2206-landing-part-unknown.fixed.md new file mode 100644 index 000000000..b4a87a05f --- /dev/null +++ b/changelog.d/2206-landing-part-unknown.fixed.md @@ -0,0 +1 @@ +The main view no longer hands the shell a conversation its session list does not know, so loading a Bot stops throwing the two uncaught `sessions.retain: unknown session` errors from the landing timer: a part the list has not listed yet falls back to the chat it replaced, and when the list knows none of them the view simply stays where it is ([#2206](https://github.com/ThinkFlowLab/FlyBot/issues/2206), reported in [#1306](https://github.com/ThinkFlowLab/FlyBot/issues/1306)). diff --git a/core/client.js b/core/client.js index e4f0eba37..7da4f3072 100644 --- a/core/client.js +++ b/core/client.js @@ -2509,12 +2509,16 @@ var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team se var currentPart = (bot) => bot.sessionId ?? bot.id; function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { const bot = last ? roster.byId[last.botId] : void 0; - if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); - if (bot && stay) return last.sessionId; const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; - if (held) return last.sessionId; const main = roster.byId[mainId]; - return main ? currentPart(main) : mainId; + const destinations = [ + bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : void 0, + bot && stay ? last.sessionId : void 0, + held ? last.sessionId : void 0, + main ? currentPart(main) : mainId + ].filter((id) => id !== void 0); + if (sessions === void 0) return destinations[0]; + return destinations.find((id) => sessions.byId?.[id] !== void 0); } function reminderPrompts(text2) { const one = /^reminder_prompt_json: (.*)$/m.exec(text2); @@ -22427,8 +22431,9 @@ function apply(ctx) { landOn: async (id, last, { stay = false, view = "" } = {}) => { if (last) await pull().catch(() => { }); - setPlain(false); const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }); + if (target === void 0) return noteNavigation("refused", id, last?.sessionId); + setPlain(false); noteNavigation("landOn", target, last?.sessionId); expected = target; ctx.uiWorkspace.openSession(target); diff --git a/core/src/client/index.js b/core/src/client/index.js index 8f48d3b1c..b568bc42c 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -451,8 +451,11 @@ export function apply(ctx) { // was archived for a fresh one, the view goes on in the fresh one. landOn: async (id, last, { stay = false, view = '' } = {}) => { if (last) await pull().catch(() => { /* a stale team list is fixed by the next pull */ }) - setPlain(false) const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }) + // #1306: no destination the session list knows. The shell would throw, and this call comes from + // a 400 ms timer with no click behind it; leaving the view where it is keeps the page quiet. + if (target === undefined) return noteNavigation('refused', id, last?.sessionId) + setPlain(false) noteNavigation('landOn', target, last?.sessionId) expected = target ctx.uiWorkspace.openSession(target) diff --git a/core/src/client/text.js b/core/src/client/text.js index d192ba38c..c4f81a061 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -84,12 +84,22 @@ export const currentPart = bot => bot.sessionId ?? bot.id // and still goes to the Main Bot. export function landingPart(roster, last, mainId, { stay = false, view = '', sessions } = {}) { const bot = last ? roster.byId[last.botId] : undefined - if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot) - if (bot && stay) return last.sessionId + // In this order: the Bot's current part, the chat the view held, the Main Bot. const held = last !== null && last !== undefined && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== undefined - if (held) return last.sessionId const main = roster.byId[mainId] - return main ? currentPart(main) : mainId + const destinations = [ + bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : undefined, + bot && stay ? last.sessionId : undefined, + held ? last.sessionId : undefined, + main ? currentPart(main) : mainId, + ].filter(id => id !== undefined) + // A caller that passed no session list gets the id fallback above, as it always did. + if (sessions === undefined) return destinations[0] + // #1306: the shell throws synchronously on an id its session list does not know + // (`sessions.retain: unknown session`), and this landing runs from a timer with no click behind it. + // With the list in hand only a destination that list knows is returned: a fresh part it has not + // caught up with falls back to the chat it replaced, and nothing known at all is `undefined`. + return destinations.find(id => sessions.byId?.[id] !== undefined) } // The prompts in a due scheduled task's message. DSH frames it for the model: one // task as `reminder_prompt_json: "…"`, several as `reminders_json: [{reminder_prompt}]`. diff --git a/core/test/land-on.test.js b/core/test/land-on.test.js index 62e482b5c..8008da7da 100644 --- a/core/test/land-on.test.js +++ b/core/test/land-on.test.js @@ -73,3 +73,18 @@ test('a navigation with no user activation behind it is noted, with where it wen clearClientErrors() } }) + +// #1306: the landing runs from a 400 ms timer with no click behind it, and the shell throws +// synchronously on an id its session list does not know (`sessions.retain: unknown session`) — +// the two uncaught errors in the report. When the caller passes the list, a destination that +// list does not know is never returned: a fresh part the list has not caught up with falls back +// to the chat it replaced, and with nothing known there is no destination at all. +test('#1306: a destination the session list does not know is never handed to the shell', () => { + const pending = { byId: {}, phase: 'pending' } + assert.equal(landingPart(roster, null, 'chief', { sessions: pending }), undefined, 'a session list that has not loaded: no destination') + assert.equal(landingPart(roster, last, 'chief', { stay: true, sessions: pending }), undefined, 'the held chat is not in the list either') + const fresh = { ...friend, sessionId: 'friend-2' } + const gone = { byId: { 'chief-2': { id: 'chief-2' } } } + assert.equal(landingPart({ byId: { friend: fresh, chief } }, last, 'chief', { stay: true, sessions: gone }), 'chief-2', 'a fresh part the list has not caught up with, the chat it replaced gone: the Main Bot') + assert.equal(landingPart({ byId: { friend: fresh, chief } }, last, 'chief', { stay: true, sessions }), 'friend-1', 'the fresh part is unknown, the chat it replaced is known: stay on that chat') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index fb53ce491..4c8729699 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -77,7 +77,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t ### Main view landing and navigation trace (#261) -`useLandOnMainBot` (`sidebar.js`) puts an emptied main view back on a Bot. `landingPart` (`text.js`) picks where: a part archived for a fresh one goes on in the fresh part; a view that emptied while its chat is still the Bot's current part (a session list reload, not the user leaving) returns to that same chat; a chat whose Bot the roster no longer lists (a revoked share, a removal) stays put while its session is still in the session list, including a blank chat of that same session, because the session list — not the roster — says whether the conversation exists (#261); a New Session the user opened, or a session archived together with its Bot, goes to the Main Bot. Every `openSession` and `landOn` with no user activation behind it (`navigator.userActivation.isActive === false`) is written to the client diagnostics and the console (`navigation: landOn to … from … with no click (caller stack)`), so a jump a user cannot explain names its caller. +`useLandOnMainBot` (`sidebar.js`) puts an emptied main view back on a Bot. `landingPart` (`text.js`) picks where: a part archived for a fresh one goes on in the fresh part; a view that emptied while its chat is still the Bot's current part (a session list reload, not the user leaving) returns to that same chat; a chat whose Bot the roster no longer lists (a revoked share, a removal) stays put while its session is still in the session list, including a blank chat of that same session, because the session list — not the roster — says whether the conversation exists (#261); a New Session the user opened, or a session archived together with its Bot, goes to the Main Bot. The session list also decides whether an id may be handed to the shell at all: the shell's `replaceMain` calls `sessions.retain`, which **throws synchronously** on an id the list does not know (`sessions.retain: unknown session`), and the landing runs from a 400 ms timer with no click behind it — so since #2206 a caller that passes the list gets only a destination that list knows, a fresh part it has not listed yet falls back to the chat it replaced, and with nothing known `landingPart` answers `undefined` and `landOn` records `refused` and leaves the view where it is instead of an uncaught error. Every `openSession` and `landOn` with no user activation behind it (`navigator.userActivation.isActive === false`) is written to the client diagnostics and the console (`navigation: landOn to … from … with no click (caller stack)`), so a jump a user cannot explain names its caller. ### Secrets page scope control (#545) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 26b1b4d7d..ce414aeec 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2736,7 +2736,7 @@ diff --git a/src/client/text.js b/src/client/text.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: 'numeric', minute: '2-digit' }) } -@@ -48,6 +48,24 @@ export function relativeShort(ms) { +@@ -48,6 +48,34 @@ export function relativeShort(ms) { export const contentText = content => (content ?? []).filter(block => block?.type === 'text').map(block => block.text).join('\n') export const HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/ export const currentPart = bot => bot.sessionId ?? bot.id @@ -2751,12 +2751,22 @@ diff --git a/src/client/text.js b/src/client/text.js +// and still goes to the Main Bot. +export function landingPart(roster, last, mainId, { stay = false, view = '', sessions } = {}) { + const bot = last ? roster.byId[last.botId] : undefined -+ if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot) -+ if (bot && stay) return last.sessionId ++ // In this order: the Bot's current part, the chat the view held, the Main Bot. + const held = last !== null && last !== undefined && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== undefined -+ if (held) return last.sessionId + const main = roster.byId[mainId] -+ return main ? currentPart(main) : mainId ++ const destinations = [ ++ bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : undefined, ++ bot && stay ? last.sessionId : undefined, ++ held ? last.sessionId : undefined, ++ main ? currentPart(main) : mainId, ++ ].filter(id => id !== undefined) ++ // A caller that passed no session list gets the id fallback above, as it always did. ++ if (sessions === undefined) return destinations[0] ++ // #1306: the shell throws synchronously on an id its session list does not know ++ // (`sessions.retain: unknown session`), and this landing runs from a timer with no click behind it. ++ // With the list in hand only a destination that list knows is returned: a fresh part it has not ++ // caught up with falls back to the chat it replaced, and nothing known at all is `undefined`. ++ return destinations.find(id => sessions.byId?.[id] !== undefined) +} // The prompts in a due scheduled task's message. DSH frames it for the model: one // task as `reminder_prompt_json: "…"`, several as `reminders_json: [{reminder_prompt}]`. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index a7be22527..1dd439ddc 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1646,7 +1646,7 @@ diff --git a/src/client/index.js b/src/client/index.js // A DSH Agent Session id lands on the plain surface, not the Bot skin. if (isAgentSessionId(id)) return actions.openAgentSession(id) ui.set(state => (state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state)) -@@ -409,11 +409,11 @@ export function apply(ctx) { +@@ -409,11 +409,14 @@ export function apply(ctx) { }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -1655,9 +1655,12 @@ diff --git a/src/client/index.js b/src/client/index.js - const bot = last ? roster.getSnapshot().byId[last.botId] : undefined + landOn: async (id, last, { stay = false, view = '' } = {}) => { + if (last) await pull().catch(() => { /* a stale team list is fixed by the next pull */ }) ++ const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }) ++ // #1306: no destination the session list knows. The shell would throw, and this call comes from ++ // a 400 ms timer with no click behind it; leaving the view where it is keeps the page quiet. ++ if (target === undefined) return noteNavigation('refused', id, last?.sessionId) setPlain(false) - const target = bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : partFor(id) -+ const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }) + noteNavigation('landOn', target, last?.sessionId) expected = target ctx.uiWorkspace.openSession(target) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 32d022621..a22782175 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1964,7 +1964,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,9 +1483,18 @@ function relativeShort(ms) { +@@ -1483,9 +1483,22 @@ function relativeShort(ms) { var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1973,12 +1973,16 @@ diff --git a/client.js b/client.js - const batch = /^reminders_json: (.*)$/m.exec(text); +function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { + const bot = last ? roster.byId[last.botId] : void 0; -+ if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); -+ if (bot && stay) return last.sessionId; + const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; -+ if (held) return last.sessionId; + const main = roster.byId[mainId]; -+ return main ? currentPart(main) : mainId; ++ const destinations = [ ++ bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : void 0, ++ bot && stay ? last.sessionId : void 0, ++ held ? last.sessionId : void 0, ++ main ? currentPart(main) : mainId ++ ].filter((id) => id !== void 0); ++ if (sessions === void 0) return destinations[0]; ++ return destinations.find((id) => sessions.byId?.[id] !== void 0); +} +function reminderPrompts(text2) { + const one = /^reminder_prompt_json: (.*)$/m.exec(text2); @@ -24834,7 +24838,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13653,12 @@ function apply(ctx) { +@@ -13653,12 +13653,13 @@ function apply(ctx) { }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -24843,9 +24847,10 @@ diff --git a/client.js b/client.js if (last) await pull().catch(() => { }); - const bot = last ? roster.getSnapshot().byId[last.botId] : void 0; ++ const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }); ++ if (target === void 0) return noteNavigation("refused", id, last?.sessionId); setPlain(false); - const target = bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : partFor(id); -+ const target = landingPart(roster.getSnapshot(), last, id, { stay, view, sessions: ctx.sessions.list.getSnapshot() }); + noteNavigation("landOn", target, last?.sessionId); expected = target; ctx.uiWorkspace.openSession(target); diff --git a/patches/ds-bot/files/test/land-on.test.js b/patches/ds-bot/files/test/land-on.test.js index 62e482b5c..8008da7da 100644 --- a/patches/ds-bot/files/test/land-on.test.js +++ b/patches/ds-bot/files/test/land-on.test.js @@ -73,3 +73,18 @@ test('a navigation with no user activation behind it is noted, with where it wen clearClientErrors() } }) + +// #1306: the landing runs from a 400 ms timer with no click behind it, and the shell throws +// synchronously on an id its session list does not know (`sessions.retain: unknown session`) — +// the two uncaught errors in the report. When the caller passes the list, a destination that +// list does not know is never returned: a fresh part the list has not caught up with falls back +// to the chat it replaced, and with nothing known there is no destination at all. +test('#1306: a destination the session list does not know is never handed to the shell', () => { + const pending = { byId: {}, phase: 'pending' } + assert.equal(landingPart(roster, null, 'chief', { sessions: pending }), undefined, 'a session list that has not loaded: no destination') + assert.equal(landingPart(roster, last, 'chief', { stay: true, sessions: pending }), undefined, 'the held chat is not in the list either') + const fresh = { ...friend, sessionId: 'friend-2' } + const gone = { byId: { 'chief-2': { id: 'chief-2' } } } + assert.equal(landingPart({ byId: { friend: fresh, chief } }, last, 'chief', { stay: true, sessions: gone }), 'chief-2', 'a fresh part the list has not caught up with, the chat it replaced gone: the Main Bot') + assert.equal(landingPart({ byId: { friend: fresh, chief } }, last, 'chief', { stay: true, sessions }), 'friend-1', 'the fresh part is unknown, the chat it replaced is known: stay on that chat') +})