on master help sign in
Keep auto-sort going past a tab the model refuses, and ignore unread counts

A request Apple's filter refused, or the model answered badly, used to stop the whole sort and was sent again unchanged on every tab event. Now the request is tried once without page text, then split in halves until the one tab it cannot take is found; that tab is set aside until its page or title changes and the rest are sorted. A timeout splits once before giving up. Tabs that went to another page, closed, or were grouped by hand while the model was thinking are left out of its answer, and a new group left with one tab is not made. Join candidates beyond twelve are the groups in use, not the oldest, and the Restored group is never offered as a topic. An unread count or bullet in a title ("(3) Inbox") no longer counts as a new page for auto-sort, renaming or the groups' change tracking. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commit333a70e795b3fc7be31f628c2602d52b26871ad1 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 11:44 (2 weeks ago) parent39441a5
10 files changed+312−114
src/background/engine.ts+3−1 src/background/index.ts+1 src/background/naming.ts+2−2 src/background/organize.ts+186−107 src/background/state.ts+2 src/background/tidy.ts+1−1 src/shared/label.ts+7 test/auto.test.ts+83−2 test/organize.test.ts+17 test/url.test.ts+10−1
src/background/engine.ts+3−1
@@ -1,6 +1,7 @@
11import type { GroupColor } from "../shared/colors";
22import { isColor } from "../shared/colors";
33import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4+import { titleKey } from "../shared/label";
45import type { Settings } from "./settings";
56import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
67
@@ -113,7 +114,7 @@ const handlers: Handlers = {
113114 else loose(rec, ctx, out);
114115 return;
115116 }
116− const titleChanged = before.title !== rec.title;
117+ const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
117118 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
118119 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
119120 if (before.groupId !== rec.groupId) {
@@ -423,6 +424,7 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
423424 if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
424425 if (before?.parkedAt) rec.parkedAt = before.parkedAt;
425426 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
427+ if (before?.organizeRefused) rec.organizeRefused = before.organizeRefused;
426428 if (before?.handPlaced) rec.handPlaced = true;
427429 if (before?.fitPending) rec.fitPending = true;
428430 s.tabs[tab.id] = rec;
src/background/index.ts+1
@@ -381,6 +381,7 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
381381 parkedAt: prev?.parkedAt,
382382 keepLoose: prev?.keepLoose,
383383 organizedKey: prev?.organizedKey,
384+ organizeRefused: prev?.organizeRefused,
384385 handPlaced: prev?.handPlaced,
385386 fitPending: prev?.fitPending,
386387 createdAt: prev?.createdAt ?? now,
src/background/naming.ts+2−2
@@ -1,4 +1,4 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
44import type { HostOpts, HostReply } from "./host";
@@ -60,7 +60,7 @@ export const settled = (t: { status?: string }): boolean => t.status !== "loadin
6060
6161/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
6262export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
63− sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
63+ sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
6464
6565export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
6666
src/background/organize.ts+186−107
@@ -1,10 +1,11 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
44import { pathKey } from "./engine";
55import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
66import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7−import { markDirty, type State } from "./state";
7+import type { HostReply } from "./host";
8+import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
89
910/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1011
@@ -151,88 +152,192 @@ export interface OrganizeReport {
151152 error?: string;
152153}
153154
154−/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
155+/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
155156export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
157+ const members = new Map<number, TabRecord[]>();
158+ for (const t of Object.values(state.tabs)) if (t.groupId !== -1) members.set(t.groupId, [...(members.get(t.groupId) ?? []), t]);
159+ const inUse = (g: GroupRecord): number =>
160+ Math.max(g.dirtyAt ?? 0, g.lastNamedAt ?? 0, ...(members.get(g.id) ?? []).map((t) => t.lastActivatedAt ?? 0));
156161 const groups = Object.values(state.groups)
157− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
158− .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
159− .slice(0, EXISTING_GROUPS_CAP);
162+ .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && !g.restored && (g.stripTitle || g.title))
163+ .map((g) => ({ g, first: extraIds.includes(g.id), at: inUse(g) }))
164+ .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
165+ .slice(0, EXISTING_GROUPS_CAP)
166+ .map(({ g }) => g);
160167 const list = groups.map((g, i) => ({
161168 g: i,
162169 title: labelOf(g.stripTitle || g.title),
163− samples: Object.values(state.tabs)
164− .filter((t) => t.groupId === g.id)
165− .slice(0, 2)
166− .map((t) => t.title.slice(0, 60)),
170+ samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
167171 }));
168172 return { ids: groups.map((g) => g.id), list };
169173}
170174
171−export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
175+/** Errors about what was sent rather than about the host: a smaller or plainer request may go through. */
176+const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_MODEL_OUTPUT"]);
177+const MAX_TIMEOUT_SPLITS = 1;
178+
179+/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
180+async function askOrganize(rt: Runtime, part: Member[], existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
181+ const settings = rt.settings();
182+ const payload: OrganizePayload = {
183+ items: toItems(part, settings, withDescriptions),
184+ existingGroups,
185+ allowNew: part.length >= 2,
186+ maxGroups: maxGroupsFor(part.length),
187+ minGroupSize: settings.organizeMinGroupSize,
188+ };
189+ let reply = await rt.host<OrganizeResult>("organize", payload);
190+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
191+ if (!reply.ok && reply.error.code === "GUARDRAIL" && payload.items.some((i) => i.description)) {
192+ // What a page says is the likeliest thing to trip Apple's filter; its title and address rarely do.
193+ payload.items = toItems(part, settings, false);
194+ reply = await rt.host<OrganizeResult>("organize", payload);
195+ }
196+ return reply;
197+}
198+
199+/** One organize run over a window: what it made, what it moved, and whether the host stopped it. */
200+export interface SortRun {
201+ windowId: number;
202+ createdIds: number[];
203+ previous: Record<number, number>;
204+ grouped: number;
205+ left: number;
206+ timeoutSplits: number;
207+ /** Auto-organize: remember what was looked at, so the same tabs are not sent again unchanged. */
208+ stamp: boolean;
209+ stopped?: HostError;
210+}
211+
212+export const newRun = (windowId: number, stamp: boolean): SortRun => ({ windowId, createdIds: [], previous: {}, grouped: 0, left: 0, timeoutSplits: 0, stamp });
213+
214+/**
215+ * Sort one part of a window. A reply about the tabs themselves (too long, refused by Apple's filter, not
216+ * valid output) splits the part in halves, so one page the model won't handle leaves the others sorted; a
217+ * tab that fails alone is set aside until its page or title changes. Tabs that changed while the model was
218+ * thinking are left out of its answer.
219+ */
220+export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe = false): Promise<void> {
221+ if (!part.length || run.stopped) return;
222+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), run.windowId, run.createdIds);
223+ if (part.length < 2 && !existingIds.length && !probe) {
224+ run.left += part.length;
225+ return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
226+ }
227+ const reply = await askOrganize(rt, part, existingGroups, true);
228+ if (!reply.ok) {
229+ const e = reply.error;
230+ const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
231+ if (part.length > 1 && (CONTENT_ERRORS.has(e.code) || timedOut)) {
232+ if (timedOut) run.timeoutSplits++;
233+ const half = Math.ceil(part.length / 2);
234+ await sortPart(rt, part.slice(0, half), run, true);
235+ await sortPart(rt, part.slice(half), run, true);
236+ return;
237+ }
238+ if (CONTENT_ERRORS.has(e.code)) {
239+ const rec = rt.state().tabs[part[0].id];
240+ if (rec) rec.organizeRefused = organizedKeyOf(part[0]);
241+ rt.log("organize: set aside a tab the model would not sort", e.code);
242+ run.left += 1;
243+ return;
244+ }
245+ run.stopped = e;
246+ return;
247+ }
172248 const settings = rt.settings();
249+ const plan = planFromResult(reply.result, part.map((m) => m.id), existingIds, settings.organizeMinGroupSize, maxGroupsFor(part.length), (ids) =>
250+ colorFor(part.find((m) => m.id === ids[0])?.url ?? ""),
251+ );
252+ const unchanged = await stillAsSent(rt, part, run.windowId);
253+ const trimmed = trimPlan(rt.state(), plan, unchanged, settings.organizeMinGroupSize);
254+ const failed = await applyPlan(rt, trimmed, run.windowId, part, run.previous, run.createdIds);
255+ const placed = [...trimmed.joins, ...trimmed.creates].reduce((n, x) => n + x.tabIds.filter((id) => !failed.has(id)).length, 0);
256+ run.grouped += placed;
257+ run.left += part.length - placed;
258+ if (run.stamp) {
259+ const s = rt.state();
260+ for (const m of part) {
261+ const rec = s.tabs[m.id];
262+ if (rec && unchanged.has(m.id) && !failed.has(m.id)) rec.organizedKey = organizedKeyOf(m);
263+ }
264+ }
265+}
266+
267+/** The tabs of a part that are still loose, in this window, on the page and title the model saw. */
268+async function stillAsSent(rt: Runtime, part: Member[], windowId: number): Promise<Set<number>> {
269+ const live = new Map((await chrome.tabs.query({ windowId }).catch(() => [])).map((t) => [t.id!, t]));
270+ const s = rt.state();
271+ return new Set(
272+ part
273+ .filter((m) => {
274+ const t = live.get(m.id);
275+ return !!t && organizable(t) && !s.tabs[m.id]?.keepLoose && organizedKeyOf(t) === organizedKeyOf(m);
276+ })
277+ .map((m) => m.id),
278+ );
279+}
280+
281+/** A plan with the tabs that moved on taken out: a new group left too small is not made, a join into a group gone is dropped. */
282+export function trimPlan(state: State, plan: Plan, keep: Set<number>, minGroupSize: number): Plan {
283+ const out: Plan = { joins: [], creates: [], leftovers: plan.leftovers.filter((id) => keep.has(id)) };
284+ for (const j of plan.joins) {
285+ const tabIds = j.tabIds.filter((id) => keep.has(id));
286+ const g = state.groups[j.groupId];
287+ if (tabIds.length && g?.managed && !g.restored) out.joins.push({ ...j, tabIds });
288+ else out.leftovers.push(...tabIds);
289+ }
290+ for (const c of plan.creates) {
291+ const tabIds = c.tabIds.filter((id) => keep.has(id));
292+ if (tabIds.length >= Math.max(2, minGroupSize)) out.creates.push({ ...c, tabIds });
293+ else out.leftovers.push(...tabIds);
294+ }
295+ return out;
296+}
297+
298+export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
299+ const s0 = rt.state();
173300 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
174301 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
175− if (tabs.length < 2) {
302+ // A tab the model already would not sort, on the same page, is not sent again.
303+ const sendable = tabs.filter((t) => s0.tabs[t.id!]?.organizeRefused !== organizedKeyOf(t));
304+ if (sendable.length < 2) {
176305 report.left = tabs.length;
177306 return report;
178307 }
179− const members: Member[] = tabs.map((t) => ({
180− id: t.id!,
181− index: t.index,
182− url: t.url ?? "",
183− title: t.title ?? "",
184− status: t.status,
185− description: rt.state().tabs[t.id!]?.description,
186− lastActive: t.lastAccessed ?? 0,
187− }));
188− const previous: Record<number, number> = {};
189− const createdIds: number[] = [];
190− const queue = makeBatches(members);
308+ const run = newRun(windowId, false);
191309 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
192310 rt.refreshBadge();
193311 try {
194− while (queue.length) {
195− const batch = queue.shift()!;
196− const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), windowId, createdIds);
197− const maxGroups = maxGroupsFor(batch.length);
198− const payload: OrganizePayload = {
199− items: toItems(batch, settings),
200− existingGroups,
201− allowNew: true,
202− maxGroups,
203− minGroupSize: settings.organizeMinGroupSize,
204− };
205− let reply = await rt.host<OrganizeResult>("organize", payload);
206− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
207− const half = Math.ceil(batch.length / 2);
208− queue.unshift(batch.slice(0, half), batch.slice(half));
209− continue;
210− }
211− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
212− if (!reply.ok) {
213− report.error = `${reply.error.code}: ${reply.error.message}`;
214− report.left += batch.length + queue.flat().length;
215− break;
216− }
217− const batchIds = batch.map((m) => m.id);
218− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, maxGroups, (ids) =>
219− colorFor(members.find((m) => m.id === ids[0])?.url ?? ""),
220− );
221− await applyPlan(rt, plan, windowId, members, previous, createdIds);
222− report.grouped += plan.joins.reduce((n, j) => n + j.tabIds.length, 0) + plan.creates.reduce((n, c) => n + c.tabIds.length, 0);
223− report.left += plan.leftovers.length;
312+ for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
313+ await sortPart(rt, batch, run);
314+ if (run.stopped) break;
224315 }
225316 } finally {
226317 const s = rt.state();
227318 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
228− report.groups = createdIds.length;
229− if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
319+ report.grouped = run.grouped;
320+ report.groups = run.createdIds.length;
321+ report.left = tabs.length - run.grouped;
322+ if (run.stopped) report.error = `${run.stopped.code}: ${run.stopped.message}`;
323+ if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
230324 rt.commit();
231325 rt.refreshBadge();
232326 }
233327 return report;
234328}
235329
330+const toMember = (s: State, t: chrome.tabs.Tab): Member => ({
331+ id: t.id!,
332+ index: t.index,
333+ url: t.url ?? "",
334+ title: t.title ?? "",
335+ status: t.status,
336+ description: s.tabs[t.id!]?.description,
337+ lastActive: t.lastAccessed ?? 0,
338+});
339+
340+/** Carry out a plan; returns the tabs whose move failed, which stay as they were. */
236341export async function applyPlan(
237342 rt: Runtime,
238343 plan: Plan,
@@ -240,11 +345,15 @@ export async function applyPlan(
240345 members: Member[],
241346 previous: Record<number, number>,
242347 createdIds: number[],
243−): Promise<void> {
348+): Promise<Set<number>> {
244349 const settings = rt.settings();
245350 const byId = new Map(members.map((m) => [m.id, m]));
351+ const failed = new Set<number>();
246352 for (const j of plan.joins) {
247− if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
353+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
354+ for (const id of j.tabIds) failed.add(id);
355+ continue;
356+ }
248357 for (const id of j.tabIds) previous[id] = -1;
249358 const g = rt.state().groups[j.groupId];
250359 if (g) {
@@ -265,7 +374,10 @@ export async function applyPlan(
265374 lastNamedAt: named ? rt.now() : undefined,
266375 dirty: false,
267376 });
268− if (groupId === undefined) continue;
377+ if (groupId === undefined) {
378+ for (const id of c.tabIds) failed.add(id);
379+ continue;
380+ }
269381 createdIds.push(groupId);
270382 for (const id of c.tabIds) previous[id] = -1;
271383 const g = rt.state().groups[groupId];
@@ -274,6 +386,7 @@ export async function applyPlan(
274386 rt.naming.touch(groupId);
275387 }
276388 }
389+ return failed;
277390}
278391
279392/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -301,7 +414,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
301414}
302415
303416/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
304−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
417+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
305418
306419export const AUTO_RETRY_MS = 300_000;
307420export const AUTO_CONTINUE_MS = 1_500;
@@ -379,59 +492,25 @@ export class AutoOrganizer {
379492 const s = rt.state();
380493 // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
381494 // their title and address, so they're sorted like loaded ones.
382− const loose = tabs.filter((t) => organizable(t) && settled(t) && !s.tabs[t.id!]?.keepLoose);
495+ const loose = tabs.filter((t) => {
496+ const rec = s.tabs[t.id!];
497+ return organizable(t) && settled(t) && !rec?.keepLoose && rec?.organizeRefused !== organizedKeyOf(t);
498+ });
383499 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
384500 if (!fresh.length) return;
385− const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
386− if (loose.length < 2 && !existingIds.length) return; // one loose tab and nothing to join: wait for company
387− const toMember = (t: chrome.tabs.Tab): Member => ({
388− id: t.id!,
389− index: t.index,
390− url: t.url ?? "",
391− title: t.title ?? "",
392− status: t.status,
393− description: s.tabs[t.id!]?.description,
394− lastActive: t.lastAccessed ?? 0,
395− });
396− const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map(toMember);
501+ if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.length) return; // one loose tab and nothing to join: wait for company
502+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
397503 const batch = makeBatches(ordered)[0];
398− const maxGroups = maxGroupsFor(batch.length);
399− const payload: OrganizePayload = {
400− items: toItems(batch, settings),
401− existingGroups,
402− allowNew: batch.length >= 2,
403− maxGroups,
404− minGroupSize: settings.organizeMinGroupSize,
405− };
406− let reply = await rt.host<OrganizeResult>("organize", payload);
407− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
408− // Rare with the budgeted batch; try once with half, the rest follows on the next run.
409− const half = batch.slice(0, Math.ceil(batch.length / 2));
410− payload.items = toItems(half, settings);
411− payload.maxGroups = maxGroupsFor(half.length);
412− payload.allowNew = half.length >= 2;
413− batch.length = half.length;
414− reply = await rt.host<OrganizeResult>("organize", payload);
415− }
416− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
417− if (!reply.ok) {
418− rt.log("auto-organize", reply.error.code);
504+ const run = newRun(windowId, true);
505+ await sortPart(rt, batch, run);
506+ rt.commit();
507+ if (run.stopped) {
508+ rt.log("auto-organize", run.stopped.code);
419509 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
420510 return;
421511 }
422− const batchIds = batch.map((m) => m.id);
423− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, payload.maxGroups, (ids) =>
424− colorFor(batch.find((m) => m.id === ids[0])?.url ?? ""),
425− );
426− await applyPlan(rt, plan, windowId, batch, {}, []);
427− const live = rt.state();
428− for (const m of batch) {
429− const rec = live.tabs[m.id];
430− if (rec) rec.organizedKey = organizedKeyOf(m);
431− }
432− rt.commit();
433− const remaining = fresh.filter((t) => !batchIds.includes(t.id!));
434− if (remaining.length) this.touch(windowId, AUTO_CONTINUE_MS);
512+ const sent = new Set(batch.map((m) => m.id));
513+ if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
435514 }
436515}
437516
src/background/state.ts+2
@@ -21,6 +21,7 @@ export interface TabRecord {
2121 parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
2222 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2323 organizedKey?: string; // page + title when auto-organize last considered this tab
24+ organizeRefused?: string; // page + title the model would not sort even alone: not sent again until it changes
2425 handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
2526 fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
2627}
@@ -48,6 +49,7 @@ export interface GroupRecord {
4849 nextAttemptAt?: number;
4950 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
5051 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
52+ restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
5153}
5254
5355/** A group Chromium removed, kept for a day: it may come back (moved to another window, reopened, restored). */
src/background/tidy.ts+1−1
@@ -361,7 +361,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
361361 if (t.id !== undefined) ids.push(t.id);
362362 }
363363 if (ids.length) {
364− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
364+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
365365 }
366366 return ids.length;
367367}
src/shared/label.ts+7
@@ -61,3 +61,10 @@ export function labelOf(title: string | undefined): string {
6161
6262export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
6363 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
64+
65+/**
66+ * A page title without the unread counts and bullets sites add to it, "(3) Inbox" or "• Slack", so a count
67+ * ticking over does not read as a new page. Only for noticing change: the model still sees the full title.
68+ */
69+export const titleKey = (title: string | undefined): string =>
70+ (title ?? "").replace(/[(\[]\d[\d,.]*\+?[)\]]/g, "").replace(/^[\s•●▶*]+/u, "").replace(/\s+/g, " ").trim();
test/auto.test.ts+83−2
@@ -15,6 +15,7 @@ let state: State;
1515let calls: OrganizePayload[];
1616let replies: HostReply<unknown>[];
1717let fallbacks: number[];
18+let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
1819
1920const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
2021 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -32,9 +33,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
3233 settings: () => makeSettings(),
3334 commit: () => undefined,
3435 now: () => 1_000_000,
35− host: async <T,>(_op: string, payload: object) => {
36+ host: async <T,>(_op: string, payload: object, opts?: object) => {
3637 calls.push(JSON.parse(JSON.stringify(payload)));
37− return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
38+ return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
3839 },
3940 naming: { touch: () => undefined } as unknown as Runtime["naming"],
4041 refreshBadge: () => undefined,
@@ -48,6 +49,7 @@ beforeEach(() => {
4849 calls = [];
4950 replies = [];
5051 fallbacks = [];
52+ respond = undefined;
5153 nextGroup = 100;
5254 (globalThis as any).chrome = {
5355 tabs: {
@@ -149,4 +151,83 @@ describe("auto-organize", () => {
149151 vi.useRealTimers();
150152 }
151153 });
154+
155+ it("one tab the model refuses is found and set aside; the others are still sorted", async () => {
156+ const auto = setup([liveTab(1), liveTab(2), liveTab(3, { title: "Refused" }), liveTab(4), liveTab(5)]);
157+ // Apple's filter refuses any request carrying tab 3; anything else becomes one topic.
158+ respond = (p) =>
159+ p.items.some((i) => i.title === "Refused")
160+ ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
161+ : { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", existing: p.existingGroups.length ? 0 : undefined, members: p.items.map((i) => i.i) }], leftovers: [] } };
162+ await auto.run(1);
163+ expect(live.find((t) => t.id === 3)!.groupId).toBe(-1);
164+ expect(live.filter((t) => t.id !== 3).every((t) => t.groupId === 100)).toBe(true);
165+ expect(state.tabs[3].organizeRefused).toBe(organizedKeyOf(live[2]));
166+ expect(fallbacks).toHaveLength(0); // not a host problem: no retry scheduled
167+
168+ const n = calls.length;
169+ live.push(liveTab(6));
170+ remember(live[5]);
171+ await auto.run(1);
172+ expect(calls.slice(n).flatMap((c) => c.items.map((i) => i.title))).toEqual(["Tab 6"]); // the refused tab is not sent again
173+
174+ const m = calls.length;
175+ live[2].title = "Readable now";
176+ await auto.run(1);
177+ expect(calls.length).toBeGreaterThan(m); // a new title: it gets another chance
178+ });
179+
180+ it("a refused request is tried once more without what the pages say", async () => {
181+ const auto = setup([liveTab(1), liveTab(2)]);
182+ state.tabs[1].description = "Something the filter dislikes";
183+ respond = (p) =>
184+ p.items.some((i) => i.description)
185+ ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
186+ : { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [] } };
187+ await auto.run(1);
188+ expect(calls).toHaveLength(2);
189+ expect(live.map((t) => t.groupId)).toEqual([100, 100]);
190+ });
191+
192+ it("tabs that changed while the model was thinking are left out of its answer", async () => {
193+ const auto = setup([liveTab(1), liveTab(2), liveTab(3), liveTab(4)]);
194+ respond = () => {
195+ live[1].url = "https://elsewhere.com/"; // tab 2 went to another page
196+ live = live.filter((t) => t.id !== 4); // tab 4 was closed
197+ return { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", members: [0, 1, 2] }, { title: "Other", emoji: "📚", color: "red", members: [3] }], leftovers: [] } };
198+ };
199+ await auto.run(1);
200+ expect(live.map((t) => [t.id, t.groupId])).toEqual([[1, 100], [2, -1], [3, 100]]);
201+ expect(state.tabs[1].organizedKey).toBeDefined();
202+ expect(state.tabs[2].organizedKey).toBeUndefined(); // looked at again on its new page
203+ });
204+
205+ it("a new group left with one tab after the others moved on is not made", async () => {
206+ const auto = setup([liveTab(1), liveTab(2), liveTab(3)]);
207+ respond = () => {
208+ live[1].groupId = 77; // you dragged tab 2 into a group of yours meanwhile
209+ return { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [2] } };
210+ };
211+ await auto.run(1);
212+ expect(live.find((t) => t.id === 1)!.groupId).toBe(-1);
213+ expect(nextGroup).toBe(100);
214+ });
215+
216+ it("an unread count ticking over is not a new page", async () => {
217+ const auto = setup([liveTab(1, { title: "(3) Inbox" }), liveTab(2)]);
218+ await auto.run(1);
219+ expect(calls).toHaveLength(1);
220+ live[0].title = "(4) Inbox";
221+ await auto.run(1);
222+ expect(calls).toHaveLength(1);
223+ });
224+
225+ it("a host that stops answering stops the run after one smaller try", async () => {
226+ const auto = setup(Array.from({ length: 10 }, (_, i) => liveTab(i + 1)));
227+ respond = () => ({ ok: false, error: { code: "TIMEOUT", message: "slow" } });
228+ await auto.run(1);
229+ expect(calls.map((c) => c.items.length)).toEqual([10, 5]);
230+ expect(fallbacks).toHaveLength(1);
231+ expect(Object.values(state.tabs).some((t) => t.organizeRefused)).toBe(false);
232+ });
152233});
test/organize.test.ts+17
@@ -107,6 +107,23 @@ describe("existing groups", () => {
107107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
108108 });
109109
110+ it("offers the groups in use when there are more than 12, not the oldest", () => {
111+ const s = emptyState();
112+ for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}`, lastNamedAt: 1000 - i });
113+ s.tabs[1] = { id: 1, windowId: 1, groupId: 14, index: 0, url: "u", title: "Now", pinned: false, createdAt: 0, lastActivatedAt: 5000 };
114+ const { ids } = existingGroupsFor(s, 1);
115+ expect(ids[0]).toBe(14);
116+ expect(ids).toContain(0);
117+ expect(ids).not.toContain(13);
118+ });
119+
120+ it("leaves out the Restored group, which is not a topic", () => {
121+ const s = emptyState();
122+ s.groups[1] = newGroupRecord(1, 1, "organize", "grey", { userNamed: true, stripTitle: "♻️ Restored", restored: true });
123+ s.groups[2] = newGroupRecord(2, 1, "organize", "red", { stripTitle: "🦀 Rust" });
124+ expect(existingGroupsFor(s, 1).ids).toEqual([2]);
125+ });
126+
110127 it("groups created by batch n come first for batch n+1", () => {
111128 const s = emptyState();
112129 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/url.test.ts+10−1
@@ -1,5 +1,5 @@
11import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
33import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
44import { withDefaults } from "../src/background/settings";
55
@@ -74,6 +74,15 @@ describe("labels", () => {
7474 expect(safeEmoji("🦄")).toBe("🧭");
7575 });
7676
77+ it("titleKey ignores the unread counts and bullets sites put in titles", () => {
78+ expect(titleKey("(3) Inbox - Mail")).toBe(titleKey("(12) Inbox - Mail"));
79+ expect(titleKey("Inbox (1,204) - me@example.com - Gmail")).toBe("Inbox - me@example.com - Gmail");
80+ expect(titleKey("• Slack | general")).toBe("Slack | general");
81+ expect(titleKey("[99+] Chat")).toBe("Chat");
82+ expect(titleKey("Rust async")).toBe("Rust async");
83+ expect(titleKey("Rust async")).not.toBe(titleKey("Rust sync"));
84+ });
85+
7786 it("labelOf strips our emoji prefix", () => {
7887 expect(labelOf("🦀 Rust async")).toBe("Rust async");
7988 expect(labelOf("Plain title")).toBe("Plain title");