fix: prefer the focused frame, bound model length, drop dead flag

This commit is contained in:
golem 2026-08-01 23:15:46 -06:00
parent d7821e3dc7
commit 51bf4d5c01
6 changed files with 37 additions and 13 deletions

@ -10,6 +10,17 @@ const COMMAND_ID = "ask-chatgpt";
// A commands invocation confers activeTab (ext-commands.js grants it before // A commands invocation confers activeTab (ext-commands.js grants it before
// firing onCommand), so executeScript works with no host permission. // firing onCommand), so executeScript works with no host permission.
// //
// Every frame keeps its own independent Selection, and a selection made in a
// non-focused frame is not cleared when the user selects elsewhere. Without a
// filter, a stale selection in an unfocused (possibly cross-origin) iframe
// could win over — or be pulled alongside — the selection the user actually
// made, with the winner among multiple non-empty results decided by an
// executeScript ordering that isn't specified. The injected snippet is
// therefore gated on document.hasFocus(), which is true for the focused
// document and all of its ancestors: the top frame wins when the user
// selected there, and an iframe's selection is only picked up when it is
// itself the focused frame.
//
// Failure has three measured shapes, all handled here: a thrown // Failure has three measured shapes, all handled here: a thrown
// "Missing host permission for the tab", a thrown variant naming frames, and a // "Missing host permission for the tab", a thrown variant naming frames, and a
// SILENT resolution to [null] on parent-process about: pages. Never index into // SILENT resolution to [null] on parent-process about: pages. Never index into
@ -32,7 +43,7 @@ async function readSelection() {
let results; let results;
try { try {
results = await browser.tabs.executeScript(tabId, { results = await browser.tabs.executeScript(tabId, {
code: "window.getSelection().toString()", code: "document.hasFocus() ? window.getSelection().toString() : ''",
allFrames: true, allFrames: true,
matchAboutBlank: true, matchAboutBlank: true,
}); });

@ -4,6 +4,13 @@
export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"];
// An unbounded model string can, on its own, exceed MAX_URL_CHARS in
// lib/build-url.js — fitToBudget() only ever trims `q`, so a long enough
// model silently pushes the whole URL over budget and drops the query
// entirely with no signal to the user. 200 is generous for any real model
// slug while keeping the URL budget meaningful.
export const MAX_MODEL_CHARS = 200;
export const DEFAULT_SETTINGS = { export const DEFAULT_SETTINGS = {
model: "", model: "",
temporaryChat: false, temporaryChat: false,

@ -1,4 +1,4 @@
import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; import { DEFAULT_SETTINGS, OPEN_IN_VALUES, MAX_MODEL_CHARS } from "./defaults.js";
// Pure. Layers stored values over the defaults, dropping unknown keys and // Pure. Layers stored values over the defaults, dropping unknown keys and
// falling back on wrong types or out-of-range values. A fresh or signed-out // falling back on wrong types or out-of-range values. A fresh or signed-out
@ -8,7 +8,7 @@ export function mergeSettings(stored) {
if (stored === null || typeof stored !== "object") { if (stored === null || typeof stored !== "object") {
return merged; return merged;
} }
if (typeof stored.model === "string") { if (typeof stored.model === "string" && stored.model.length <= MAX_MODEL_CHARS) {
merged.model = stored.model; merged.model = stored.model;
} }
if (typeof stored.temporaryChat === "boolean") { if (typeof stored.temporaryChat === "boolean") {

@ -9,7 +9,7 @@
<form id="settings" autocomplete="off"> <form id="settings" autocomplete="off">
<div class="field"> <div class="field">
<label for="model">Model <span class="tag">experimental</span></label> <label for="model">Model <span class="tag">experimental</span></label>
<input type="text" id="model" placeholder="auto" spellcheck="false" /> <input type="text" id="model" placeholder="auto" spellcheck="false" maxlength="200" />
<p class="hint"> <p class="hint">
ChatGPT may ignore this and use your account default. Leave blank to ChatGPT may ignore this and use your account default. Leave blank to
always use your account default. always use your account default.

@ -8,12 +8,8 @@ const field = (id) => document.getElementById(id);
let saveTimer = null; let saveTimer = null;
let statusTimer = null; let statusTimer = null;
// Suppresses the change/input handlers while we are programmatically writing
// values into the form, so a remote sync cannot trigger a save loop.
let applying = false;
function render(settings) { function render(settings) {
applying = true;
for (const [id, value] of Object.entries(settings)) { for (const [id, value] of Object.entries(settings)) {
const element = field(id); const element = field(id);
// Never overwrite the control the user is actively editing — this page's // Never overwrite the control the user is actively editing — this page's
@ -27,7 +23,6 @@ function render(settings) {
element.value = value; element.value = value;
} }
} }
applying = false;
} }
function collect() { function collect() {
@ -60,9 +55,6 @@ async function persist(settings) {
// Debounced so typing in a text field cannot approach any write-rate quota. // Debounced so typing in a text field cannot approach any write-rate quota.
function scheduleSave() { function scheduleSave() {
if (applying) {
return;
}
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS);
} }

@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DEFAULT_SETTINGS } from "../lib/defaults.js"; import { DEFAULT_SETTINGS, MAX_MODEL_CHARS } from "../lib/defaults.js";
import { mergeSettings } from "../lib/settings.js"; import { mergeSettings } from "../lib/settings.js";
test("empty object yields the defaults", () => { test("empty object yields the defaults", () => {
@ -78,3 +78,17 @@ test("mergeSettings does not mutate DEFAULT_SETTINGS", () => {
mergeSettings({ model: "mutated" }); mergeSettings({ model: "mutated" });
assert.equal(DEFAULT_SETTINGS.model, ""); assert.equal(DEFAULT_SETTINGS.model, "");
}); });
// An unbounded model string can, on its own, push the finished URL over
// MAX_URL_CHARS (lib/build-url.js), which silently drops `q` — the user's
// selection vanishes with no signal. mergeSettings() must bound `model` the
// same way it already bounds an out-of-range openIn: fall back to the default.
test("a model at exactly MAX_MODEL_CHARS is accepted", () => {
const model = "m".repeat(MAX_MODEL_CHARS);
assert.equal(mergeSettings({ model }).model, model);
});
test("a model one character over MAX_MODEL_CHARS falls back to the default", () => {
const model = "m".repeat(MAX_MODEL_CHARS + 1);
assert.equal(mergeSettings({ model }).model, DEFAULT_SETTINGS.model);
});