on master help sign in
Keep auto-sort going past a tab the model refuses, and ignore unread counts
333a70eClaude
src/background/engine.ts+3−1
@@ -1,6 +1,7 @@
1import type { GroupColor } from "../shared/colors";
1import type { GroupColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4+import { titleKey } from "../shared/label";
4import type { Settings } from "./settings";
5import type { Settings } from "./settings";
5import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
6import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
6
7
@@ -113,7 +114,7 @@ const handlers: Handlers = {
113 else loose(rec, ctx, out);
114 else loose(rec, ctx, out);
114 return;
115 return;
115 }
116 }
116− const titleChanged = before.title !== rec.title;
117+ const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
117 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
118 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
118 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
119 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
119 if (before.groupId !== rec.groupId) {
120 if (before.groupId !== rec.groupId) {
@@ -423,6 +424,7 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
423 if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
424 if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
424 if (before?.parkedAt) rec.parkedAt = before.parkedAt;
425 if (before?.parkedAt) rec.parkedAt = before.parkedAt;
425 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
426 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
427+ if (before?.organizeRefused) rec.organizeRefused = before.organizeRefused;
426 if (before?.handPlaced) rec.handPlaced = true;
428 if (before?.handPlaced) rec.handPlaced = true;
427 if (before?.fitPending) rec.fitPending = true;
429 if (before?.fitPending) rec.fitPending = true;
428 s.tabs[tab.id] = rec;
430 s.tabs[tab.id] = rec;
src/background/index.ts+1
@@ -381,6 +381,7 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
381 parkedAt: prev?.parkedAt,
381 parkedAt: prev?.parkedAt,
382 keepLoose: prev?.keepLoose,
382 keepLoose: prev?.keepLoose,
383 organizedKey: prev?.organizedKey,
383 organizedKey: prev?.organizedKey,
384+ organizeRefused: prev?.organizeRefused,
384 handPlaced: prev?.handPlaced,
385 handPlaced: prev?.handPlaced,
385 fitPending: prev?.fitPending,
386 fitPending: prev?.fitPending,
386 createdAt: prev?.createdAt ?? now,
387 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";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4import type { HostOpts, HostReply } from "./host";
4import type { HostOpts, HostReply } from "./host";
@@ -60,7 +60,7 @@ export const settled = (t: { status?: string }): boolean => t.status !== "loadin
60
60
61/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
61/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
62export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
62export 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"));
64
64
65export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
65export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
66
66
src/background/organize.ts+186−107
@@ -1,10 +1,11 @@
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
1import { 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";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4import { pathKey } from "./engine";
4import { pathKey } from "./engine";
5import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
5import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7+import type { HostReply } from "./host";
7−import { markDirty, type State } from "./state";
8+import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
8
9
9/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
10/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
10
11
@@ -151,88 +152,192 @@ export interface OrganizeReport {
151 error?: string;
152 error?: string;
152}
153}
153
154
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. */
155export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
156export 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));
156 const groups = Object.values(state.groups)
161 const groups = Object.values(state.groups)
157− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
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) }))
158− .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
164+ .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
159− .slice(0, EXISTING_GROUPS_CAP);
165+ .slice(0, EXISTING_GROUPS_CAP)
166+ .map(({ g }) => g);
160 const list = groups.map((g, i) => ({
167 const list = groups.map((g, i) => ({
161 g: i,
168 g: i,
162 title: labelOf(g.stripTitle || g.title),
169 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)),
167 }));
171 }));
168 return { ids: groups.map((g) => g.id), list };
172 return { ids: groups.map((g) => g.id), list };
169}
173}
170
174
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+ }
172 const settings = rt.settings();
248 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();
173 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
300 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
174 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
301 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
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));
175− if (tabs.length < 2) {
304+ if (sendable.length < 2) {
176 report.left = tabs.length;
305 report.left = tabs.length;
177 return report;
306 return report;
178 }
307 }
179− const members: Member[] = tabs.map((t) => ({
308+ const run = newRun(windowId, false);
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);
191 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
309 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
192 rt.refreshBadge();
310 rt.refreshBadge();
193 try {
311 try {
194− while (queue.length) {
312+ for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
195− const batch = queue.shift()!;
313+ await sortPart(rt, batch, run);
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;
314+ if (run.stopped) 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;
224 }
315 }
225 } finally {
316 } finally {
226 const s = rt.state();
317 const s = rt.state();
227 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
318 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
319+ report.grouped = run.grouped;
228− report.groups = createdIds.length;
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}`;
229− if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
323+ if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
230 rt.commit();
324 rt.commit();
231 rt.refreshBadge();
325 rt.refreshBadge();
232 }
326 }
233 return report;
327 return report;
234}
328}
235
329
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. */
236export async function applyPlan(
341export async function applyPlan(
237 rt: Runtime,
342 rt: Runtime,
238 plan: Plan,
343 plan: Plan,
@@ -240,11 +345,15 @@ export async function applyPlan(
240 members: Member[],
345 members: Member[],
241 previous: Record<number, number>,
346 previous: Record<number, number>,
242 createdIds: number[],
347 createdIds: number[],
243−): Promise<void> {
348+): Promise<Set<number>> {
244 const settings = rt.settings();
349 const settings = rt.settings();
245 const byId = new Map(members.map((m) => [m.id, m]));
350 const byId = new Map(members.map((m) => [m.id, m]));
351+ const failed = new Set<number>();
246 for (const j of plan.joins) {
352 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+ }
248 for (const id of j.tabIds) previous[id] = -1;
357 for (const id of j.tabIds) previous[id] = -1;
249 const g = rt.state().groups[j.groupId];
358 const g = rt.state().groups[j.groupId];
250 if (g) {
359 if (g) {
@@ -265,7 +374,10 @@ export async function applyPlan(
265 lastNamedAt: named ? rt.now() : undefined,
374 lastNamedAt: named ? rt.now() : undefined,
266 dirty: false,
375 dirty: false,
267 });
376 });
268− if (groupId === undefined) continue;
377+ if (groupId === undefined) {
378+ for (const id of c.tabIds) failed.add(id);
379+ continue;
380+ }
269 createdIds.push(groupId);
381 createdIds.push(groupId);
270 for (const id of c.tabIds) previous[id] = -1;
382 for (const id of c.tabIds) previous[id] = -1;
271 const g = rt.state().groups[groupId];
383 const g = rt.state().groups[groupId];
@@ -274,6 +386,7 @@ export async function applyPlan(
274 rt.naming.touch(groupId);
386 rt.naming.touch(groupId);
275 }
387 }
276 }
388 }
389+ return failed;
277}
390}
278
391
279/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
392/** 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> {
301}
414}
302
415
303/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
416/** 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)}`;
305
418
306export const AUTO_RETRY_MS = 300_000;
419export const AUTO_RETRY_MS = 300_000;
307export const AUTO_CONTINUE_MS = 1_500;
420export const AUTO_CONTINUE_MS = 1_500;
@@ -379,59 +492,25 @@ export class AutoOrganizer {
379 const s = rt.state();
492 const s = rt.state();
380 // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
493 // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
381 // their title and address, so they're sorted like loaded ones.
494 // 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+ });
383 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
499 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
384 if (!fresh.length) return;
500 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
501+ if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.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);
502+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
397 const batch = makeBatches(ordered)[0];
503 const batch = makeBatches(ordered)[0];
398− const maxGroups = maxGroupsFor(batch.length);
504+ const run = newRun(windowId, true);
399− const payload: OrganizePayload = {
505+ await sortPart(rt, batch, run);
400− items: toItems(batch, settings),
506+ rt.commit();
401− existingGroups,
507+ if (run.stopped) {
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);
508+ rt.log("auto-organize", run.stopped.code);
419 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
509 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
420 return;
510 return;
421 }
511 }
422− const batchIds = batch.map((m) => m.id);
512+ const sent = new Set(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);
513+ if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
435 }
514 }
436}
515}
437
516
src/background/state.ts+2
@@ -21,6 +21,7 @@ export interface TabRecord {
21 parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
21 parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
22 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
22 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
23 organizedKey?: string; // page + title when auto-organize last considered this tab
23 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
24 handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
25 handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
25 fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
26 fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
26}
27}
@@ -48,6 +49,7 @@ export interface GroupRecord {
48 nextAttemptAt?: number;
49 nextAttemptAt?: number;
49 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
50 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
50 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
51 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
51}
53}
52
54
53/** A group Chromium removed, kept for a day: it may come back (moved to another window, reopened, restored). */
55/** 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>
361 if (t.id !== undefined) ids.push(t.id);
361 if (t.id !== undefined) ids.push(t.id);
362 }
362 }
363 if (ids.length) {
363 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 });
365 }
365 }
366 return ids.length;
366 return ids.length;
367}
367}
src/shared/label.ts+7
@@ -61,3 +61,10 @@ export function labelOf(title: string | undefined): string {
61
61
62export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
62export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
63 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
63 !!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;
15let calls: OrganizePayload[];
15let calls: OrganizePayload[];
16let replies: HostReply<unknown>[];
16let replies: HostReply<unknown>[];
17let fallbacks: number[];
17let fallbacks: number[];
18+let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
18
19
19const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
20const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
20 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
21 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 {
32 settings: () => makeSettings(),
33 settings: () => makeSettings(),
33 commit: () => undefined,
34 commit: () => undefined,
34 now: () => 1_000_000,
35 now: () => 1_000_000,
35− host: async <T,>(_op: string, payload: object) => {
36+ host: async <T,>(_op: string, payload: object, opts?: object) => {
36 calls.push(JSON.parse(JSON.stringify(payload)));
37 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>;
38 },
39 },
39 naming: { touch: () => undefined } as unknown as Runtime["naming"],
40 naming: { touch: () => undefined } as unknown as Runtime["naming"],
40 refreshBadge: () => undefined,
41 refreshBadge: () => undefined,
@@ -48,6 +49,7 @@ beforeEach(() => {
48 calls = [];
49 calls = [];
49 replies = [];
50 replies = [];
50 fallbacks = [];
51 fallbacks = [];
52+ respond = undefined;
51 nextGroup = 100;
53 nextGroup = 100;
52 (globalThis as any).chrome = {
54 (globalThis as any).chrome = {
53 tabs: {
55 tabs: {
@@ -149,4 +151,83 @@ describe("auto-organize", () => {
149 vi.useRealTimers();
151 vi.useRealTimers();
150 }
152 }
151 });
153 });
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+ });
152});
233});
test/organize.test.ts+17
@@ -107,6 +107,23 @@ describe("existing groups", () => {
107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
108 });
108 });
109
109
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+
110 it("groups created by batch n come first for batch n+1", () => {
127 it("groups created by batch n come first for batch n+1", () => {
111 const s = emptyState();
128 const s = emptyState();
112 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
129 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 @@
1import { describe, expect, it } from "vitest";
1import { 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";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
4import { withDefaults } from "../src/background/settings";
4import { withDefaults } from "../src/background/settings";
5
5
@@ -74,6 +74,15 @@ describe("labels", () => {
74 expect(safeEmoji("🦄")).toBe("🧭");
74 expect(safeEmoji("🦄")).toBe("🧭");
75 });
75 });
76
76
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+
77 it("labelOf strips our emoji prefix", () => {
86 it("labelOf strips our emoji prefix", () => {
78 expect(labelOf("🦀 Rust async")).toBe("Rust async");
87 expect(labelOf("🦀 Rust async")).toBe("Rust async");
79 expect(labelOf("Plain title")).toBe("Plain title");
88 expect(labelOf("Plain title")).toBe("Plain title");