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/2206-landing-part-unknown.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
15 changes: 10 additions & 5 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 14 additions & 4 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}]`.
Expand Down
15 changes: 15 additions & 0 deletions core/test/land-on.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
20 changes: 15 additions & 5 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}]`.
Expand Down
7 changes: 5 additions & 2 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
19 changes: 12 additions & 7 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions patches/ds-bot/files/test/land-on.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Loading