diff --git a/apps/codex-plus-manager/src/thread-scroll-restore.test.ts b/apps/codex-plus-manager/src/thread-scroll-restore.test.ts new file mode 100644 index 000000000..e689f4315 --- /dev/null +++ b/apps/codex-plus-manager/src/thread-scroll-restore.test.ts @@ -0,0 +1,547 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import vm from "node:vm"; + +const renderer = readFileSync(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8"); +const extract = (name: string) => { + const body = renderer.match(new RegExp(`^ function ${name}\\([^]*?^ \\}`, "m"))?.[0]; + assert.ok(body, name); + return body; +}; + +const sessionId = "thread-12345678"; +type Entry = { top: number; scrollHeight: number; clientHeight: number; at: number; atBottom?: boolean }; + +function fixture(entry?: Entry) { + const stored = new Map(); + if (entry) stored.set("codexThreadScroll", JSON.stringify({ version: "1", entries: { [sessionId]: entry } })); + stored.set("settings", JSON.stringify({ threadScrollRestore: true })); + const timers = new Map void }>(); + const scrollListeners = new Set<() => void>(); + let nextTimer = 0; + let currentSessionId = sessionId; + const scroller = { + scrollHeight: 20000, clientHeight: 1000, scrollTop: 19000, direction: "column", + scrollTo({ top }: { top: number }) { this.scrollTop = top; }, + querySelector(_selector: string): object | null { return {}; }, + closest(_selector: string): object | null { return null; }, + contains(node: unknown) { return node === this; }, + addEventListener(_name: string, listener: () => void) { scrollListeners.add(listener); }, + removeEventListener(_name: string, listener: () => void) { scrollListeners.delete(listener); }, + }; + const context = vm.createContext({ + window: {}, + document: { scrollingElement: {}, documentElement: {}, body: {} }, + localStorage: { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), + }, + codexPlusBackendSettings: { enhancementsEnabled: true } as Record, + codexPlusBackendSettingsLoaded: false, + codexPlusSettingsKey: "settings", conversationViewDefaultWidth: 900, + codexThreadScrollKey: "codexThreadScroll", codexThreadScrollVersion: "1", + codexThreadScrollMaxEntries: 120, codexThreadScrollSaveThrottleMs: 120, + codexThreadScrollRestoreWindowMs: 3200, codexThreadScrollRestoreDelaysMs: [0, 80, 220], + codexThreadScrollUserIntentWindowMs: 1200, codexThreadScrollListenerVersion: "4", + sessionKey: (id: string) => id, + currentSessionRef: () => ({ session_id: currentSessionId }), + currentThreadScroller: () => scroller, + getComputedStyle: () => ({ flexDirection: scroller.direction }), + setTimeout: (run: () => void, delay: number) => { + const id = ++nextTimer; + timers.set(id, { delay, run }); + return id; + }, + clearTimeout: (id: number) => timers.delete(id), + }); + const settingsStart = renderer.indexOf(" function defaultCodexPlusSettings()"); + const settingsEnd = renderer.indexOf(" // Dream skin runtime", settingsStart); + assert.ok(settingsStart >= 0 && settingsEnd > settingsStart); + const names = [ + "finiteNonNegativeNumber", "finiteScrollNumber", "validThreadScrollSessionKey", + "normalizedCodexThreadUuid", "currentThreadScrollSessionId", + "readThreadScrollEntries", "writeThreadScrollEntries", "threadScrollRuntime", + "clearThreadScrollRestoreTimers", "clearThreadScrollSyncTimers", "clearThreadScrollRestoreLock", + "cancelThreadScrollRestoreForUserIntent", "userScrollIntentActive", "threadScrollRestoreCancelledForSession", + "activeThreadScrollRestoreLock", "currentThreadScrollRestoreLock", "threadScrollIsReversed", "threadScrollRange", + "startThreadScrollRestoreLock", "prepareThreadScrollRestoreLock", "threadScrollTargetTop", + "threadScrollNearBottom", "threadScrollGuardScroller", "shouldBlockThreadScrollAutobottom", + "bindThreadScrollListener", "saveThreadScrollPositionNow", "scheduleThreadScrollSave", + "restoreThreadScrollPosition", "scheduleThreadScrollRestore", "syncThreadScrollState", + "scheduleThreadScrollSyncAttempts", "captureThreadScrollNavigation", + ]; + if (renderer.includes(" function threadScrollRestoreEnabled(")) names.push("threadScrollRestoreEnabled"); + vm.runInContext([renderer.slice(settingsStart, settingsEnd), ...names.map(extract)].join("\n"), context); + const flushTimers = () => { + while (timers.size) { + const [id, timer] = [...timers.entries()].sort((a, b) => a[1].delay - b[1].delay || a[0] - b[0])[0]; + timers.delete(id); + timer.run(); + } + }; + return { + context, scroller, stored, timers, scrollListeners, flushTimers, + setSessionId: (value: string) => { currentSessionId = value; }, + }; +} + +const reading: Entry = { top: 2000, scrollHeight: 6000, clientHeight: 1000, at: 1 }; +const bottom: Entry = { top: 5000, scrollHeight: 6000, clientHeight: 1000, at: 1 }; + +function scrollClickFixture(context: vm.Context, scroller: ReturnType["scroller"]) { + const listeners = new Map void>(); + const root = {}; + const otherRoot = {}; + const footer = {}; + scroller.closest = (selector) => selector === "[data-request-input-activity-root]" ? root : null; + context.document.addEventListener = (name: string, handler: (event: object) => void) => listeners.set(name, handler); + context.document.removeEventListener = () => {}; + context.selectors = { sidebarThread: "[data-sidebar-thread]" }; + vm.runInContext([ + extract("eventTargetsActiveThreadScroller"), extract("markThreadScrollUserIntent"), + extract("installThreadScrollNavigationCapture"), + ].join("\n"), context); + context.window.__codexThreadScrollHandlers = { + markUserIntent: context.markThreadScrollUserIntent, + captureNavigation: () => {}, + }; + context.installThreadScrollNavigationCapture(); + const target = ({ sameRoot = true, inFooter = true, arrow = true, dialog = false, icon = false } = {}) => { + const button = { + classList: { contains: (name: string) => arrow && name === "group/scroll-to-bottom" }, + closest: (selector: string) => { + if (selector === "[data-request-input-activity-root]") return sameRoot ? root : otherRoot; + if (selector === "[data-thread-scroll-footer], .conversation-footer") return inFooter ? footer : null; + if (selector === "dialog, [role='dialog']") return dialog ? {} : null; + if (selector === "button") return button; + return null; + }, + }; + return icon ? { closest: (selector: string) => selector === "button" ? button : null } : button; + }; + return { + click: (element: object) => listeners.get("click")!({ type: "click", target: element }), + target, + }; +} + +test("native bottom-button clicks cancel pending restores and persist the bottom", () => { + for (const icon of [false, true]) { + const { context, scroller, stored, scrollListeners, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + const { click, target } = scrollClickFixture(context, scroller); + context.syncThreadScrollState(); + assert.ok(context.threadScrollRuntime().restoreLock); + click(target({ icon })); + assert.equal(context.threadScrollRuntime().restoreLock, null); + assert.equal(context.shouldBlockThreadScrollAutobottom(scroller, 19000), false); + scroller.scrollTo({ top: 19000 }); + for (const listener of scrollListeners) listener(); + flushTimers(); + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 19000); + assert.equal(JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId].atBottom, true); + } +}); + +test("clicks on unrelated controls or another pane leave the pending restore intact", () => { + for (const options of [{ arrow: false }, { inFooter: false }, { sameRoot: false }, { dialog: true }]) { + const { context, scroller, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + const { click, target } = scrollClickFixture(context, scroller); + context.syncThreadScrollState(); + click(target(options)); + assert.ok(context.threadScrollRuntime().restoreLock, JSON.stringify(options)); + flushTimers(); + assert.equal(scroller.scrollTop, reading.top, JSON.stringify(options)); + } +}); + +test("a bottom-button click cancels the visible session even while the previous session is active", () => { + const nextId = "thread-87654321"; + const { context, scroller, stored, setSessionId, flushTimers } = fixture(reading); + stored.set("codexThreadScroll", JSON.stringify({ version: "1", entries: { + [sessionId]: reading, [nextId]: reading, + } })); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + const { click, target } = scrollClickFixture(context, scroller); + context.syncThreadScrollState(); + setSessionId(nextId); + click(target({ icon: true })); + scroller.scrollTo({ top: 19000 }); + context.syncThreadScrollState(); + flushTimers(); + assert.equal(scroller.scrollTop, 19000); + assert.equal(context.threadScrollRuntime().userCancelledRestoreSessionId, nextId); +}); + +test("bottom-button clicks respect unloaded settings and inactive enhancements", () => { + for (const state of ["waiting", "disabled", "enhancements", "builtin"]) { + const { context, scroller, flushTimers } = fixture(reading); + if (state === "disabled") { + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + } else if (state === "enhancements") { + context.codexPlusBackendSettings.enhancementsEnabled = false; + } else if (state === "builtin") { + context.__codexPlusBuiltinInactive = new Set(["thread-scroll-restore"]); + } + const { click, target } = scrollClickFixture(context, scroller); + context.syncThreadScrollState(); + click(target({ icon: true })); + if (state === "disabled" || state === "enhancements" || state === "builtin") { + assert.equal(context.threadScrollRuntime().userScrollIntentUntil, 0, state); + continue; + } + assert.equal(context.threadScrollRuntime().userCancelledRestoreSessionId, sessionId, state); + scroller.scrollTo({ top: 19000 }); + if (state === "waiting") { + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + } + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 19000, state); + } +}); + +test("selects the visible thread scroller when a hidden conversation remains mounted", () => { + const hidden = { isConnected: true, clientWidth: 0, clientHeight: 0 }; + const visible = { isConnected: true, clientWidth: 900, clientHeight: 720 }; + const document = { + querySelector: () => hidden, + querySelectorAll: () => [hidden, visible], + }; + const context = vm.createContext({ document }); + vm.runInContext(extract("currentThreadScroller"), context); + assert.equal(context.currentThreadScroller(), visible); + document.querySelectorAll = () => [hidden]; + assert.equal(context.currentThreadScroller(), null); +}); + +test("identifies the visible conversation when its sidebar row is absent", () => { + const { context, scroller, flushTimers, setSessionId } = fixture(reading); + const visibleId = "01a18420-78e4-79c1-8ef2-9e0ce9fc61aa"; + const marker = (id: string, width: number) => ({ + getAttribute: () => id, + closest: () => ({ getBoundingClientRect: () => ({ width }) }), + }); + context.document.querySelectorAll = () => [ + marker("01a18420-78e4-79c1-8ef2-9e0ce9fc61ab", 0), + marker(visibleId, 900), + ]; + vm.runInContext([extract("normalizedCodexThreadUuid"), extract("codexPlusCurrentSessionId")].join("\n"), context); + setSessionId(""); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + scroller.scrollTop = 2000; + context.syncThreadScrollState(); + flushTimers(); + assert.equal(context.threadScrollRuntime().activeSessionId, visibleId); + assert.equal(scroller.scrollTop, 2000); +}); + +test("restores a saved reading position when the visible composer identifies the conversation", () => { + const { context, scroller, stored, flushTimers, setSessionId } = fixture(); + const visibleId = "01a18420-78e4-79c1-8ef2-9e0ce9fc61aa"; + stored.set("codexThreadScroll", JSON.stringify({ version: "1", entries: { [visibleId]: reading } })); + context.document.querySelectorAll = () => [{ + getAttribute: () => visibleId, + closest: () => ({ getBoundingClientRect: () => ({ width: 900 }) }), + }]; + vm.runInContext([extract("normalizedCodexThreadUuid"), extract("codexPlusCurrentSessionId")].join("\n"), context); + setSessionId(""); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(); + flushTimers(); + assert.equal(context.threadScrollRuntime().activeSessionId, visibleId); + assert.equal(scroller.scrollTop, reading.top); +}); + +test("does not use a hidden composer to identify the current conversation", () => { + const { context, setSessionId } = fixture(reading); + context.document.querySelectorAll = () => [{ + getAttribute: () => "01a18420-78e4-79c1-8ef2-9e0ce9fc61aa", + closest: () => ({ getBoundingClientRect: () => ({ width: 0 }) }), + }]; + vm.runInContext([extract("normalizedCodexThreadUuid"), extract("codexPlusCurrentSessionId")].join("\n"), context); + setSessionId(""); + context.syncThreadScrollState(); + assert.equal(context.threadScrollRuntime().activeSessionId, ""); +}); + +test("missing, partial, or disabled backend settings cannot restore, save, or block native scrolling", () => { + for (const [loaded, setting] of [[false, undefined], [true, undefined], [true, false]] as const) { + const { context, scroller, stored, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = loaded; + if (setting !== undefined) context.codexPlusBackendSettings.codexAppThreadScrollRestore = setting; + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 19000, `loaded=${loaded} setting=${setting}`); + context.startThreadScrollRestoreLock(sessionId, reading); + assert.equal(context.shouldBlockThreadScrollAutobottom(scroller, 19000), false); + scroller.scrollTop = 1234; + context.saveThreadScrollPositionNow(sessionId, scroller); + assert.equal(JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId].top, reading.top); + } +}); + +test("disabled restoration leaves positions unchanged when switching conversations", () => { + const { context, scroller, flushTimers, setSessionId } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + scroller.scrollTop = 2000; + context.syncThreadScrollState(); + flushTimers(); + assert.equal(scroller.scrollTop, 2000); + + context.captureThreadScrollNavigation("thread-87654321"); + setSessionId("thread-87654321"); + scroller.scrollTop = 1500; + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 1500); +}); + +test("disabling restoration cancels a pending restore", () => { + const { context, scroller, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(); + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + context.syncThreadScrollState(); + scroller.scrollTop = 1500; + flushTimers(); + assert.equal(scroller.scrollTop, 1500); + assert.equal(context.shouldBlockThreadScrollAutobottom(scroller, 19000), false); +}); + +test("a delayed restore cannot move the previous session's position into a new conversation", () => { + const { context, scroller, setSessionId } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(); + setSessionId("thread-87654321"); + scroller.scrollTop = 1500; + context.restoreThreadScrollPosition(sessionId); + assert.equal(scroller.scrollTop, 1500); +}); + +test("disabled enhancements or an inactive builtin do not restore a position", () => { + for (const inactive of [false, true]) { + const { context, scroller, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + if (inactive) { + context.__codexPlusBuiltinInactive = new Set(["thread-scroll-restore"]); + } else { + context.codexPlusBackendSettings.enhancementsEnabled = false; + } + scroller.scrollTop = 2000; + context.syncThreadScrollState(); + flushTimers(); + assert.equal(scroller.scrollTop, 2000); + } +}); + +test("user input does not activate scroll handling when restoration is disabled", () => { + for (const inactive of ["setting", "enhancements", "builtin"]) { + const { context, scroller } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = inactive !== "setting"; + if (inactive === "enhancements") context.codexPlusBackendSettings.enhancementsEnabled = false; + if (inactive === "builtin") context.__codexPlusBuiltinInactive = new Set(["thread-scroll-restore"]); + context.eventTargetsActiveThreadScroller = () => true; + vm.runInContext(extract("markThreadScrollUserIntent"), context); + context.markThreadScrollUserIntent({ target: scroller }); + assert.equal(context.threadScrollRuntime().userScrollIntentUntil, 0); + } +}); + +test("enabled restoration preserves an actual reading position", () => { + const { context, scroller, flushTimers } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 2000); + assert.equal(context.shouldBlockThreadScrollAutobottom(scroller, 19000), true); +}); + +test("a formerly bottom-aligned legacy or new record follows a growing bottom", () => { + for (const entry of [bottom, { ...bottom, atBottom: true }]) { + const { context, scroller, flushTimers } = fixture(entry); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 19000); + assert.equal(context.shouldBlockThreadScrollAutobottom(scroller, 19000), false); + scroller.scrollHeight = 24000; + context.restoreThreadScrollPosition(sessionId); + assert.equal(scroller.scrollTop, 23000); + } +}); + +test("a reversed scroller records and restores its bottom", () => { + const { context, scroller, stored, flushTimers } = fixture(); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + scroller.direction = "column-reverse"; + scroller.scrollTop = 0; + context.saveThreadScrollPositionNow(sessionId, scroller); + assert.equal(JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId].atBottom, true); + scroller.scrollTop = -5000; + context.syncThreadScrollState(true); + flushTimers(); + assert.equal(scroller.scrollTop, 0); +}); + +test("zero-size measurements neither replace good entries nor restore stale ones", () => { + const { context, scroller, stored } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + scroller.scrollHeight = 0; + scroller.clientHeight = 0; + scroller.scrollTop = 0; + context.saveThreadScrollPositionNow(sessionId, scroller); + assert.equal(JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId].top, reading.top); + const invalid = { top: 0, scrollHeight: 0, clientHeight: 0, at: 2 }; + context.window.__codexThreadScrollEntries = { [sessionId]: invalid }; + assert.equal(context.readThreadScrollEntries()[sessionId], undefined, "cached entries survive reinjection"); + context.window.__codexThreadScrollEntries = null; + stored.set("codexThreadScroll", JSON.stringify({ version: "1", entries: { [sessionId]: invalid } })); + assert.equal(context.readThreadScrollEntries()[sessionId], undefined, "persisted entries are also ignored"); +}); + +test("an unloaded non-scrollable conversation does not overwrite a saved reading position", () => { + const { context, scroller, stored } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + scroller.scrollHeight = 1000; + scroller.clientHeight = 1000; + scroller.scrollTop = 0; + scroller.querySelector = () => null; + context.saveThreadScrollPositionNow(sessionId, scroller); + assert.deepEqual(JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId], reading); +}); + +test("a loaded short conversation can replace an older saved position", () => { + const { context, scroller, stored } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + scroller.scrollHeight = 1000; + scroller.clientHeight = 1000; + scroller.scrollTop = 0; + context.saveThreadScrollPositionNow(sessionId, scroller); + const entry = JSON.parse(stored.get("codexThreadScroll")!).entries[sessionId]; + assert.equal(entry.top, 0); + assert.equal(entry.atBottom, true); +}); + +test("disabling restoration removes the active scroll listener", () => { + const { context, scrollListeners } = fixture(reading); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + context.syncThreadScrollState(true); + assert.equal(scrollListeners.size, 1); + vm.runInContext(extract("stopThreadScrollRestore"), context); + context.stopThreadScrollRestore(); + assert.equal(scrollListeners.size, 0); +}); + +test("a late settings load starts restoration once, without repeating it on each heartbeat", () => { + const { context } = fixture(reading); + const listeners = new Map void>(); + let starts = 0; + let stops = 0; + Object.assign(context, { + runScanStep: (fn: () => void) => fn(), + scheduleDeferredScan: () => {}, + stopThreadScrollRestore: () => { stops++; }, + scheduleThreadScrollSyncAttempts: () => { starts++; }, + }); + vm.runInContext([extract("codexPlusAlignWith"), extract("codexPlusThreadScrollRestoreExtension")].join("\n"), context); + context.codexPlusThreadScrollRestoreExtension({ + onScan() {}, + on: (name: string, handler: () => void) => listeners.set(name, handler), + effect: (fn: () => () => void) => fn(), + }); + assert.equal(starts, 0); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + listeners.get("settings/loaded")!(); + listeners.get("settings/loaded")!(); + assert.equal(starts, 1); + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + listeners.get("settings/loaded")!(); + assert.ok(stops > 0); + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + listeners.get("settings/changed")!(); + assert.equal(starts, 1, "re-enabling does not restore over the current reading position"); +}); + +test("enabling restoration after it was disabled does not move the reader", () => { + for (const scanned of [false, true]) { + const { context, scroller, scrollListeners, flushTimers } = fixture(reading); + const listeners = new Map void>(); + Object.assign(context, { + runScanStep: (fn: () => void) => fn(), + scheduleDeferredScan: () => {}, + }); + vm.runInContext([ + extract("stopThreadScrollRestore"), extract("scheduleThreadScrollSync"), + extract("codexPlusAlignWith"), extract("codexPlusThreadScrollRestoreExtension"), + ].join("\n"), context); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = false; + context.codexPlusThreadScrollRestoreExtension({ + onScan() {}, + on: (name: string, handler: () => void) => listeners.set(name, handler), + effect: (fn: () => () => void) => fn(), + }); + if (scanned) context.syncThreadScrollState(); + scroller.scrollTop = 1500; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + listeners.get("settings/changed")!(); + flushTimers(); + assert.equal(scroller.scrollTop, 1500); + assert.equal(scrollListeners.size, 1); + } +}); + +test("a user scroll before settings load is not overwritten by late restoration", () => { + const { context, scroller, scrollListeners, flushTimers } = fixture(reading); + const listeners = new Map void>(); + Object.assign(context, { + runScanStep: (fn: () => void) => fn(), + scheduleDeferredScan: () => {}, + }); + vm.runInContext([ + extract("stopThreadScrollRestore"), extract("scheduleThreadScrollSync"), + extract("markThreadScrollUserIntent"), extract("codexPlusAlignWith"), + extract("codexPlusThreadScrollRestoreExtension"), + ].join("\n"), context); + context.codexPlusThreadScrollRestoreExtension({ + onScan() {}, + on: (name: string, handler: () => void) => listeners.set(name, handler), + effect: (fn: () => () => void) => fn(), + }); + context.syncThreadScrollState(); + scroller.scrollTop = 1500; + context.eventTargetsActiveThreadScroller = () => true; + context.markThreadScrollUserIntent({ target: scroller }); + context.codexPlusBackendSettingsLoaded = true; + context.codexPlusBackendSettings.codexAppThreadScrollRestore = true; + listeners.get("settings/loaded")!(); + flushTimers(); + assert.equal(scroller.scrollTop, 1500); + assert.equal(scrollListeners.size, 1); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 2731e963e..71179a53a 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -3451,6 +3451,7 @@ /** 滚动位置恢复关掉时(设置关、或内置拓展停用)撤掉排着的保存 / 恢复定时器,作废进行中的轮次。 */ function stopThreadScrollRestore() { + if (window.__codexThreadScrollRuntime?.activeScroller) bindThreadScrollListener(null); clearTimeout(window.__codexThreadScrollSaveTimer); window.__codexThreadScrollSaveTimer = null; window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; @@ -9823,6 +9824,12 @@ return match ? decodeURIComponent(match[1]) : ""; } + function threadScrollRestoreEnabled() { + return codexPlusBackendSettingsLoaded + && codexPlusBackendSettings.codexAppThreadScrollRestore === true + && codexPlusSettings().threadScrollRestore; + } + function finiteNonNegativeNumber(value) { const numeric = Number(value); return Number.isFinite(numeric) && numeric >= 0 ? numeric : 0; @@ -9848,9 +9855,15 @@ return { session_id: locationThreadId(), title: "" }; } + function currentThreadScrollSessionId() { + return validThreadScrollSessionKey(currentSessionRef().session_id) + || validThreadScrollSessionKey(codexPlusCurrentSessionId()); + } + function readThreadScrollEntries() { if (window.__codexThreadScrollEntries && typeof window.__codexThreadScrollEntries === "object") { - return { ...window.__codexThreadScrollEntries }; + return Object.fromEntries(Object.entries(window.__codexThreadScrollEntries) + .filter(([, value]) => finiteNonNegativeNumber(value?.scrollHeight) > 0 && finiteNonNegativeNumber(value?.clientHeight) > 0)); } try { const parsed = JSON.parse(localStorage.getItem(codexThreadScrollKey) || "{}"); @@ -9863,12 +9876,14 @@ Object.entries(rawEntries).forEach(([key, value]) => { const safeKey = validThreadScrollSessionKey(key); if (!safeKey || !value || typeof value !== "object") return; + if (!finiteNonNegativeNumber(value.scrollHeight) || !finiteNonNegativeNumber(value.clientHeight)) return; entries[safeKey] = { top: finiteScrollNumber(value.top), scrollHeight: finiteNonNegativeNumber(value.scrollHeight), clientHeight: finiteNonNegativeNumber(value.clientHeight), at: finiteNonNegativeNumber(value.at), }; + if (typeof value.atBottom === "boolean") entries[safeKey].atBottom = value.atBottom; }); window.__codexThreadScrollEntries = entries; return { ...entries }; @@ -9907,8 +9922,10 @@ } function currentThreadScroller() { - const explicit = document.querySelector(".thread-scroll-container"); - if (explicit?.isConnected) return explicit; + const explicit = [...document.querySelectorAll(".thread-scroll-container")]; + if (explicit.length) { + return explicit.find((node) => node.isConnected && node.clientWidth > 0 && node.clientHeight > 0) || null; + } const root = conversationRoot(); if (!root?.isConnected) return document.scrollingElement || document.documentElement; const style = getComputedStyle(root); @@ -9950,13 +9967,15 @@ threadScrollRuntime().restoreLock = null; } - function cancelThreadScrollRestoreForUserIntent() { + function cancelThreadScrollRestoreForUserIntent(sessionId) { const runtime = threadScrollRuntime(); - const cancelledSessionId = validThreadScrollSessionKey(runtime.restoreLock?.sessionId) - || validThreadScrollSessionKey(currentSessionRef().session_id) + const cancelledSessionId = validThreadScrollSessionKey(sessionId) + || validThreadScrollSessionKey(runtime.restoreLock?.sessionId) + || currentThreadScrollSessionId() || validThreadScrollSessionKey(runtime.activeSessionId); runtime.userScrollIntentUntil = Date.now() + codexThreadScrollUserIntentWindowMs; runtime.userCancelledRestoreSessionId = cancelledSessionId; + runtime.pendingNavigation = null; window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; window.__codexThreadScrollSyncRevision = (window.__codexThreadScrollSyncRevision || 0) + 1; clearThreadScrollRestoreTimers(); @@ -10011,6 +10030,9 @@ runtime.restoreLock = { sessionId: key, targetTop: finiteScrollNumber(entry.top), + scrollHeight: entry.scrollHeight, + clientHeight: entry.clientHeight, + atBottom: entry.atBottom, expiresAt: Date.now() + codexThreadScrollRestoreWindowMs, }; return runtime.restoreLock; @@ -10022,9 +10044,12 @@ if (entry) startThreadScrollRestoreLock(key, entry); } - function threadScrollTargetTop(scroller, targetTop) { + function threadScrollTargetTop(scroller, entry) { const range = threadScrollRange(scroller); - return Math.max(range.min, Math.min(range.max, finiteScrollNumber(targetTop))); + const targetTop = finiteScrollNumber(entry.targetTop ?? entry.top); + const savedBottom = threadScrollIsReversed(scroller) ? 0 : Math.max(0, entry.scrollHeight - entry.clientHeight); + const atBottom = typeof entry.atBottom === "boolean" ? entry.atBottom : Math.abs(targetTop - savedBottom) <= 2; + return atBottom ? range.bottom : Math.max(range.min, Math.min(range.max, targetTop)); } function threadScrollNearBottom(scroller, top) { @@ -10046,10 +10071,10 @@ function shouldBlockThreadScrollAutobottom(scroller, top) { const runtime = threadScrollRuntime(); const lock = currentThreadScrollRestoreLock(); - if (!lock || !codexPlusSettings().threadScrollRestore) return false; + if (!lock || !threadScrollRestoreEnabled()) return false; const guardScroller = threadScrollGuardScroller(scroller); if (runtime.applyingRestore || !guardScroller) return false; - const targetTop = threadScrollTargetTop(guardScroller, lock.targetTop); + const targetTop = threadScrollTargetTop(guardScroller, lock); return Math.abs(finiteScrollNumber(top) - targetTop) > 8 && threadScrollNearBottom(guardScroller, top); } @@ -10172,13 +10197,13 @@ } runtime.activeScroller = scroller; runtime.scrollListenerUsesWindow = nextUsesWindow; - if (!scroller || !codexPlusSettings().threadScrollRestore) return; + if (!scroller || !threadScrollRestoreEnabled()) return; const target = nextUsesWindow ? window : scroller; target.addEventListener("scroll", runtime.scrollListener, true); } function saveThreadScrollPositionNow(sessionId = threadScrollRuntime().activeSessionId, scroller = threadScrollRuntime().activeScroller) { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const runtime = threadScrollRuntime(); const key = validThreadScrollSessionKey(sessionId); if (!key || !scroller) return; @@ -10189,6 +10214,10 @@ clientHeight: finiteNonNegativeNumber(scroller.clientHeight), at: Date.now(), }; + if (!snapshot.scrollHeight || !snapshot.clientHeight) return; + // 会话加载时的空容器不能覆盖之前的阅读位置。 + if (snapshot.scrollHeight <= snapshot.clientHeight && !scroller.querySelector("[data-turn-key]")) return; + snapshot.atBottom = Math.abs(threadScrollRange(scroller).bottom - snapshot.top) <= 2; if (Math.abs(runtime.lastSavedTop - snapshot.top) < 2 && runtime.lastSavedHeight === snapshot.scrollHeight && runtime.lastSavedClientHeight === snapshot.clientHeight) return; const entries = readThreadScrollEntries(); entries[key] = snapshot; @@ -10199,7 +10228,7 @@ } function scheduleThreadScrollSave() { - if (!codexPlusSettings().threadScrollRestore || window.__codexThreadScrollSaveTimer) return; + if (!threadScrollRestoreEnabled() || window.__codexThreadScrollSaveTimer) return; window.__codexThreadScrollSaveTimer = setTimeout(() => { window.__codexThreadScrollSaveTimer = null; saveThreadScrollPositionNow(); @@ -10209,14 +10238,15 @@ function restoreThreadScrollPosition(sessionId) { const runtime = threadScrollRuntime(); const key = validThreadScrollSessionKey(sessionId); - if (!codexPlusSettings().threadScrollRestore || !key || runtime.activeSessionId !== key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; + if (!threadScrollRestoreEnabled() || !key || runtime.activeSessionId !== key + || currentThreadScrollSessionId() !== key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; const lock = activeThreadScrollRestoreLock(key); const entry = lock || readThreadScrollEntries()[key]; if (!entry) return; const scroller = currentThreadScroller(); if (!scroller) return; bindThreadScrollListener(scroller); - const targetTop = threadScrollTargetTop(scroller, lock ? lock.targetTop : entry.top); + const targetTop = threadScrollTargetTop(scroller, entry); if (Math.abs(scroller.scrollTop - targetTop) <= 1) return; runtime.applyingRestore = true; try { @@ -10236,7 +10266,7 @@ function scheduleThreadScrollRestore(sessionId) { clearThreadScrollRestoreTimers(); const key = validThreadScrollSessionKey(sessionId); - if (!codexPlusSettings().threadScrollRestore || !key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; + if (!threadScrollRestoreEnabled() || !key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; const entry = readThreadScrollEntries()[key]; if (!entry) { clearThreadScrollRestoreLock(); @@ -10253,10 +10283,9 @@ function syncThreadScrollState(forceRestore = false) { const runtime = threadScrollRuntime(); - const currentRef = currentSessionRef(); - const nextSessionId = validThreadScrollSessionKey(currentRef.session_id); + const nextSessionId = currentThreadScrollSessionId(); if (!nextSessionId) return; - if (!codexPlusSettings().threadScrollRestore) { + if (!threadScrollRestoreEnabled()) { bindThreadScrollListener(null); clearThreadScrollRestoreTimers(); clearThreadScrollRestoreLock(); @@ -10283,7 +10312,7 @@ } function scheduleThreadScrollSyncAttempts(forceRestore = true) { - const currentKey = validThreadScrollSessionKey(currentSessionRef().session_id) || validThreadScrollSessionKey(threadScrollRuntime().activeSessionId); + const currentKey = currentThreadScrollSessionId() || validThreadScrollSessionKey(threadScrollRuntime().activeSessionId); if (userScrollIntentActive() || threadScrollRestoreCancelledForSession(currentKey)) return; clearThreadScrollSyncTimers(); const syncRevision = (window.__codexThreadScrollSyncRevision || 0) + 1; @@ -10295,7 +10324,7 @@ } function captureThreadScrollNavigation(targetSessionId) { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const runtime = threadScrollRuntime(); const targetKey = validThreadScrollSessionKey(targetSessionId); const sessionChanged = !!targetKey && targetKey !== runtime.activeSessionId; @@ -10318,6 +10347,14 @@ } function eventTargetsActiveThreadScroller(event) { + if (event?.type === "click") { + const button = event.target?.closest?.("button"); + const root = currentThreadScroller()?.closest?.("[data-request-input-activity-root]"); + return !!root && !!button?.classList?.contains("group/scroll-to-bottom") + && !!button.closest("[data-thread-scroll-footer], .conversation-footer") + && button.closest("[data-request-input-activity-root]") === root + && !button.closest("dialog, [role='dialog']"); + } const runtime = threadScrollRuntime(); const scroller = threadScrollGuardScroller(runtime.activeScroller) || threadScrollGuardScroller(currentThreadScroller()); if (!scroller) return false; @@ -10327,8 +10364,12 @@ } function markThreadScrollUserIntent(event) { - if (!codexPlusSettings().threadScrollRestore || !eventTargetsActiveThreadScroller(event)) return; - cancelThreadScrollRestoreForUserIntent(); + const waitingForSettings = (!codexPlusBackendSettingsLoaded + || typeof codexPlusBackendSettings.codexAppThreadScrollRestore !== "boolean") + && codexPlusBackendSettings.enhancementsEnabled !== false + && !globalThis.__codexPlusBuiltinInactive?.has("thread-scroll-restore"); + if ((!threadScrollRestoreEnabled() && !waitingForSettings) || !eventTargetsActiveThreadScroller(event)) return; + cancelThreadScrollRestoreForUserIntent(event?.type === "click" ? currentThreadScrollSessionId() : null); } function markThreadScrollKeyboardIntent(event) { @@ -10378,19 +10419,20 @@ document.removeEventListener("click", window.__codexThreadScrollClickNavigationHandler, true); document.removeEventListener("keydown", window.__codexThreadScrollKeyboardHandler, true); const navigationHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; window.__codexThreadScrollHandlers?.captureNavigation?.(sessionRefFromRow(row).session_id); }; const clickHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + window.__codexThreadScrollHandlers?.markUserIntent?.(event); + if (!threadScrollRestoreEnabled()) return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; window.__codexThreadScrollHandlers?.captureNavigation?.(sessionRefFromRow(row).session_id); }; const keyboardHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; if (event.key !== "Enter" && event.key !== " ") return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; @@ -24885,12 +24927,27 @@ scheduleThreadScrollSync(true); }, { phase: "light" }); ctx.onScan(() => scheduleThreadScrollSync()); - const stopWhenOff = () => { - if (!codexPlusSettings().threadScrollRestore) stopThreadScrollRestore(); + let enabled = threadScrollRestoreEnabled(); + let disabled = codexPlusBackendSettingsLoaded && codexPlusBackendSettings.codexAppThreadScrollRestore === false; + const onSettings = () => { + const nextEnabled = threadScrollRestoreEnabled(); + if (enabled && !nextEnabled) { + stopThreadScrollRestore(); + threadScrollRuntime().activeSessionId = currentThreadScrollSessionId(); + } + if (!enabled && nextEnabled) { + clearThreadScrollRestoreTimers(); + if (disabled) threadScrollRuntime().activeSessionId = currentThreadScrollSessionId(); + syncThreadScrollState(); + if (!disabled) scheduleThreadScrollSyncAttempts(true); + } + enabled = nextEnabled; + disabled = codexPlusBackendSettingsLoaded && codexPlusBackendSettings.codexAppThreadScrollRestore === false; }; - ctx.on("settings/changed", () => runScanStep(stopWhenOff)); + ctx.on("settings/changed", () => runScanStep(onSettings)); + ctx.on("settings/loaded", () => runScanStep(onSettings)); codexPlusAlignWith(ctx, () => { - stopWhenOff(); + if (!threadScrollRestoreEnabled()) stopThreadScrollRestore(); scheduleDeferredScan(); }); } diff --git a/assets/inject/renderer-inject/10-style.js b/assets/inject/renderer-inject/10-style.js index a91ff4517..b89c01e98 100644 --- a/assets/inject/renderer-inject/10-style.js +++ b/assets/inject/renderer-inject/10-style.js @@ -2778,6 +2778,7 @@ /** 滚动位置恢复关掉时(设置关、或内置拓展停用)撤掉排着的保存 / 恢复定时器,作废进行中的轮次。 */ function stopThreadScrollRestore() { + if (window.__codexThreadScrollRuntime?.activeScroller) bindThreadScrollListener(null); clearTimeout(window.__codexThreadScrollSaveTimer); window.__codexThreadScrollSaveTimer = null; window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; diff --git a/assets/inject/renderer-inject/60-plugin-marketplace.js b/assets/inject/renderer-inject/60-plugin-marketplace.js index 32b00204f..dcb5f22e2 100644 --- a/assets/inject/renderer-inject/60-plugin-marketplace.js +++ b/assets/inject/renderer-inject/60-plugin-marketplace.js @@ -1061,6 +1061,12 @@ return match ? decodeURIComponent(match[1]) : ""; } + function threadScrollRestoreEnabled() { + return codexPlusBackendSettingsLoaded + && codexPlusBackendSettings.codexAppThreadScrollRestore === true + && codexPlusSettings().threadScrollRestore; + } + function finiteNonNegativeNumber(value) { const numeric = Number(value); return Number.isFinite(numeric) && numeric >= 0 ? numeric : 0; @@ -1086,9 +1092,15 @@ return { session_id: locationThreadId(), title: "" }; } + function currentThreadScrollSessionId() { + return validThreadScrollSessionKey(currentSessionRef().session_id) + || validThreadScrollSessionKey(codexPlusCurrentSessionId()); + } + function readThreadScrollEntries() { if (window.__codexThreadScrollEntries && typeof window.__codexThreadScrollEntries === "object") { - return { ...window.__codexThreadScrollEntries }; + return Object.fromEntries(Object.entries(window.__codexThreadScrollEntries) + .filter(([, value]) => finiteNonNegativeNumber(value?.scrollHeight) > 0 && finiteNonNegativeNumber(value?.clientHeight) > 0)); } try { const parsed = JSON.parse(localStorage.getItem(codexThreadScrollKey) || "{}"); @@ -1101,12 +1113,14 @@ Object.entries(rawEntries).forEach(([key, value]) => { const safeKey = validThreadScrollSessionKey(key); if (!safeKey || !value || typeof value !== "object") return; + if (!finiteNonNegativeNumber(value.scrollHeight) || !finiteNonNegativeNumber(value.clientHeight)) return; entries[safeKey] = { top: finiteScrollNumber(value.top), scrollHeight: finiteNonNegativeNumber(value.scrollHeight), clientHeight: finiteNonNegativeNumber(value.clientHeight), at: finiteNonNegativeNumber(value.at), }; + if (typeof value.atBottom === "boolean") entries[safeKey].atBottom = value.atBottom; }); window.__codexThreadScrollEntries = entries; return { ...entries }; @@ -1145,8 +1159,10 @@ } function currentThreadScroller() { - const explicit = document.querySelector(".thread-scroll-container"); - if (explicit?.isConnected) return explicit; + const explicit = [...document.querySelectorAll(".thread-scroll-container")]; + if (explicit.length) { + return explicit.find((node) => node.isConnected && node.clientWidth > 0 && node.clientHeight > 0) || null; + } const root = conversationRoot(); if (!root?.isConnected) return document.scrollingElement || document.documentElement; const style = getComputedStyle(root); @@ -1188,13 +1204,15 @@ threadScrollRuntime().restoreLock = null; } - function cancelThreadScrollRestoreForUserIntent() { + function cancelThreadScrollRestoreForUserIntent(sessionId) { const runtime = threadScrollRuntime(); - const cancelledSessionId = validThreadScrollSessionKey(runtime.restoreLock?.sessionId) - || validThreadScrollSessionKey(currentSessionRef().session_id) + const cancelledSessionId = validThreadScrollSessionKey(sessionId) + || validThreadScrollSessionKey(runtime.restoreLock?.sessionId) + || currentThreadScrollSessionId() || validThreadScrollSessionKey(runtime.activeSessionId); runtime.userScrollIntentUntil = Date.now() + codexThreadScrollUserIntentWindowMs; runtime.userCancelledRestoreSessionId = cancelledSessionId; + runtime.pendingNavigation = null; window.__codexThreadScrollRestoreRevision = (window.__codexThreadScrollRestoreRevision || 0) + 1; window.__codexThreadScrollSyncRevision = (window.__codexThreadScrollSyncRevision || 0) + 1; clearThreadScrollRestoreTimers(); @@ -1249,6 +1267,9 @@ runtime.restoreLock = { sessionId: key, targetTop: finiteScrollNumber(entry.top), + scrollHeight: entry.scrollHeight, + clientHeight: entry.clientHeight, + atBottom: entry.atBottom, expiresAt: Date.now() + codexThreadScrollRestoreWindowMs, }; return runtime.restoreLock; @@ -1260,9 +1281,12 @@ if (entry) startThreadScrollRestoreLock(key, entry); } - function threadScrollTargetTop(scroller, targetTop) { + function threadScrollTargetTop(scroller, entry) { const range = threadScrollRange(scroller); - return Math.max(range.min, Math.min(range.max, finiteScrollNumber(targetTop))); + const targetTop = finiteScrollNumber(entry.targetTop ?? entry.top); + const savedBottom = threadScrollIsReversed(scroller) ? 0 : Math.max(0, entry.scrollHeight - entry.clientHeight); + const atBottom = typeof entry.atBottom === "boolean" ? entry.atBottom : Math.abs(targetTop - savedBottom) <= 2; + return atBottom ? range.bottom : Math.max(range.min, Math.min(range.max, targetTop)); } function threadScrollNearBottom(scroller, top) { @@ -1284,10 +1308,10 @@ function shouldBlockThreadScrollAutobottom(scroller, top) { const runtime = threadScrollRuntime(); const lock = currentThreadScrollRestoreLock(); - if (!lock || !codexPlusSettings().threadScrollRestore) return false; + if (!lock || !threadScrollRestoreEnabled()) return false; const guardScroller = threadScrollGuardScroller(scroller); if (runtime.applyingRestore || !guardScroller) return false; - const targetTop = threadScrollTargetTop(guardScroller, lock.targetTop); + const targetTop = threadScrollTargetTop(guardScroller, lock); return Math.abs(finiteScrollNumber(top) - targetTop) > 8 && threadScrollNearBottom(guardScroller, top); } @@ -1410,13 +1434,13 @@ } runtime.activeScroller = scroller; runtime.scrollListenerUsesWindow = nextUsesWindow; - if (!scroller || !codexPlusSettings().threadScrollRestore) return; + if (!scroller || !threadScrollRestoreEnabled()) return; const target = nextUsesWindow ? window : scroller; target.addEventListener("scroll", runtime.scrollListener, true); } function saveThreadScrollPositionNow(sessionId = threadScrollRuntime().activeSessionId, scroller = threadScrollRuntime().activeScroller) { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const runtime = threadScrollRuntime(); const key = validThreadScrollSessionKey(sessionId); if (!key || !scroller) return; @@ -1427,6 +1451,10 @@ clientHeight: finiteNonNegativeNumber(scroller.clientHeight), at: Date.now(), }; + if (!snapshot.scrollHeight || !snapshot.clientHeight) return; + // 会话加载时的空容器不能覆盖之前的阅读位置。 + if (snapshot.scrollHeight <= snapshot.clientHeight && !scroller.querySelector("[data-turn-key]")) return; + snapshot.atBottom = Math.abs(threadScrollRange(scroller).bottom - snapshot.top) <= 2; if (Math.abs(runtime.lastSavedTop - snapshot.top) < 2 && runtime.lastSavedHeight === snapshot.scrollHeight && runtime.lastSavedClientHeight === snapshot.clientHeight) return; const entries = readThreadScrollEntries(); entries[key] = snapshot; @@ -1437,7 +1465,7 @@ } function scheduleThreadScrollSave() { - if (!codexPlusSettings().threadScrollRestore || window.__codexThreadScrollSaveTimer) return; + if (!threadScrollRestoreEnabled() || window.__codexThreadScrollSaveTimer) return; window.__codexThreadScrollSaveTimer = setTimeout(() => { window.__codexThreadScrollSaveTimer = null; saveThreadScrollPositionNow(); @@ -1447,14 +1475,15 @@ function restoreThreadScrollPosition(sessionId) { const runtime = threadScrollRuntime(); const key = validThreadScrollSessionKey(sessionId); - if (!codexPlusSettings().threadScrollRestore || !key || runtime.activeSessionId !== key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; + if (!threadScrollRestoreEnabled() || !key || runtime.activeSessionId !== key + || currentThreadScrollSessionId() !== key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; const lock = activeThreadScrollRestoreLock(key); const entry = lock || readThreadScrollEntries()[key]; if (!entry) return; const scroller = currentThreadScroller(); if (!scroller) return; bindThreadScrollListener(scroller); - const targetTop = threadScrollTargetTop(scroller, lock ? lock.targetTop : entry.top); + const targetTop = threadScrollTargetTop(scroller, entry); if (Math.abs(scroller.scrollTop - targetTop) <= 1) return; runtime.applyingRestore = true; try { @@ -1474,7 +1503,7 @@ function scheduleThreadScrollRestore(sessionId) { clearThreadScrollRestoreTimers(); const key = validThreadScrollSessionKey(sessionId); - if (!codexPlusSettings().threadScrollRestore || !key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; + if (!threadScrollRestoreEnabled() || !key || userScrollIntentActive() || threadScrollRestoreCancelledForSession(key)) return; const entry = readThreadScrollEntries()[key]; if (!entry) { clearThreadScrollRestoreLock(); @@ -1491,10 +1520,9 @@ function syncThreadScrollState(forceRestore = false) { const runtime = threadScrollRuntime(); - const currentRef = currentSessionRef(); - const nextSessionId = validThreadScrollSessionKey(currentRef.session_id); + const nextSessionId = currentThreadScrollSessionId(); if (!nextSessionId) return; - if (!codexPlusSettings().threadScrollRestore) { + if (!threadScrollRestoreEnabled()) { bindThreadScrollListener(null); clearThreadScrollRestoreTimers(); clearThreadScrollRestoreLock(); @@ -1521,7 +1549,7 @@ } function scheduleThreadScrollSyncAttempts(forceRestore = true) { - const currentKey = validThreadScrollSessionKey(currentSessionRef().session_id) || validThreadScrollSessionKey(threadScrollRuntime().activeSessionId); + const currentKey = currentThreadScrollSessionId() || validThreadScrollSessionKey(threadScrollRuntime().activeSessionId); if (userScrollIntentActive() || threadScrollRestoreCancelledForSession(currentKey)) return; clearThreadScrollSyncTimers(); const syncRevision = (window.__codexThreadScrollSyncRevision || 0) + 1; @@ -1533,7 +1561,7 @@ } function captureThreadScrollNavigation(targetSessionId) { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const runtime = threadScrollRuntime(); const targetKey = validThreadScrollSessionKey(targetSessionId); const sessionChanged = !!targetKey && targetKey !== runtime.activeSessionId; @@ -1556,6 +1584,14 @@ } function eventTargetsActiveThreadScroller(event) { + if (event?.type === "click") { + const button = event.target?.closest?.("button"); + const root = currentThreadScroller()?.closest?.("[data-request-input-activity-root]"); + return !!root && !!button?.classList?.contains("group/scroll-to-bottom") + && !!button.closest("[data-thread-scroll-footer], .conversation-footer") + && button.closest("[data-request-input-activity-root]") === root + && !button.closest("dialog, [role='dialog']"); + } const runtime = threadScrollRuntime(); const scroller = threadScrollGuardScroller(runtime.activeScroller) || threadScrollGuardScroller(currentThreadScroller()); if (!scroller) return false; @@ -1565,8 +1601,12 @@ } function markThreadScrollUserIntent(event) { - if (!codexPlusSettings().threadScrollRestore || !eventTargetsActiveThreadScroller(event)) return; - cancelThreadScrollRestoreForUserIntent(); + const waitingForSettings = (!codexPlusBackendSettingsLoaded + || typeof codexPlusBackendSettings.codexAppThreadScrollRestore !== "boolean") + && codexPlusBackendSettings.enhancementsEnabled !== false + && !globalThis.__codexPlusBuiltinInactive?.has("thread-scroll-restore"); + if ((!threadScrollRestoreEnabled() && !waitingForSettings) || !eventTargetsActiveThreadScroller(event)) return; + cancelThreadScrollRestoreForUserIntent(event?.type === "click" ? currentThreadScrollSessionId() : null); } function markThreadScrollKeyboardIntent(event) { @@ -1616,19 +1656,20 @@ document.removeEventListener("click", window.__codexThreadScrollClickNavigationHandler, true); document.removeEventListener("keydown", window.__codexThreadScrollKeyboardHandler, true); const navigationHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; window.__codexThreadScrollHandlers?.captureNavigation?.(sessionRefFromRow(row).session_id); }; const clickHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + window.__codexThreadScrollHandlers?.markUserIntent?.(event); + if (!threadScrollRestoreEnabled()) return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; window.__codexThreadScrollHandlers?.captureNavigation?.(sessionRefFromRow(row).session_id); }; const keyboardHandler = (event) => { - if (!codexPlusSettings().threadScrollRestore) return; + if (!threadScrollRestoreEnabled()) return; if (event.key !== "Enter" && event.key !== " ") return; const row = event.target?.closest?.(selectors.sidebarThread); if (!row) return; diff --git a/assets/inject/renderer-inject/96-builtin-extensions.js b/assets/inject/renderer-inject/96-builtin-extensions.js index 3fa3faca2..0e3222287 100644 --- a/assets/inject/renderer-inject/96-builtin-extensions.js +++ b/assets/inject/renderer-inject/96-builtin-extensions.js @@ -277,12 +277,27 @@ scheduleThreadScrollSync(true); }, { phase: "light" }); ctx.onScan(() => scheduleThreadScrollSync()); - const stopWhenOff = () => { - if (!codexPlusSettings().threadScrollRestore) stopThreadScrollRestore(); + let enabled = threadScrollRestoreEnabled(); + let disabled = codexPlusBackendSettingsLoaded && codexPlusBackendSettings.codexAppThreadScrollRestore === false; + const onSettings = () => { + const nextEnabled = threadScrollRestoreEnabled(); + if (enabled && !nextEnabled) { + stopThreadScrollRestore(); + threadScrollRuntime().activeSessionId = currentThreadScrollSessionId(); + } + if (!enabled && nextEnabled) { + clearThreadScrollRestoreTimers(); + if (disabled) threadScrollRuntime().activeSessionId = currentThreadScrollSessionId(); + syncThreadScrollState(); + if (!disabled) scheduleThreadScrollSyncAttempts(true); + } + enabled = nextEnabled; + disabled = codexPlusBackendSettingsLoaded && codexPlusBackendSettings.codexAppThreadScrollRestore === false; }; - ctx.on("settings/changed", () => runScanStep(stopWhenOff)); + ctx.on("settings/changed", () => runScanStep(onSettings)); + ctx.on("settings/loaded", () => runScanStep(onSettings)); codexPlusAlignWith(ctx, () => { - stopWhenOff(); + if (!threadScrollRestoreEnabled()) stopThreadScrollRestore(); scheduleDeferredScan(); }); } diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index f403470b8..10565f36c 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -13,7 +13,7 @@ { "name": "10-style.js", "description": "样式注入、默认设置、后端设置映射、梦皮主题变量与持久化", - "lines": 2836 + "lines": 2837 }, { "name": "20-menu.js", @@ -68,7 +68,7 @@ { "name": "60-plugin-marketplace.js", "description": "插件市场请求/响应补丁、本地兜底与合并", - "lines": 1904 + "lines": 1945 }, { "name": "61-plugin-market-ui.js", @@ -183,7 +183,7 @@ { "name": "96-builtin-extensions.js", "description": "内置拓展:自带功能挂到拓展内核(可停用、可被 replaces 顶替)、多开身份提示、会话标题中间件", - "lines": 423 + "lines": 438 }, { "name": "96-paste-fix.js",