on master help sign in
Move a tab out of its group when it goes to an unrelated page
8a02329Claude
README.md+3−2
@@ -10,9 +10,10 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
102. **Auto-organize.** Any other loose tab (typed address, bookmark, new window) is sorted once the window has been quiet for 8 s: it joins one of Diagonal's groups, pairs up with other loose tabs on the same topic, or stays loose if nothing fits. A loose tab is looked at again only when it goes to another page or a new tab arrives that it might pair with.
102. **Auto-organize.** Any other loose tab (typed address, bookmark, new window) is sorted once the window has been quiet for 8 s: it joins one of Diagonal's groups, pairs up with other loose tabs on the same topic, or stays loose if nothing fits. A loose tab is looked at again only when it goes to another page or a new tab arrives that it might pair with.
113. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
113. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
13+5. **Drift.** When a tab in a group Diagonal made goes to a page on a different topic, it leaves the group once you switch away from it, and auto-organize files it with its new topic. Diagonal compares its topic with up to five groupmates (`src/background/fit.ts`) and only moves it when those groupmates still agree with each other.
13−5. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
14+6. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
14
15
15−Your own choices win: a tab you take out of a group stays out until it goes to another page, groups you make yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
16+Your own choices win: a tab you take out of a group stays out until it goes to another page, a tab you drag into a group stays there, groups you make or rename yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
16
17
17The model sees a tab's title, its address, and up to 500 characters of what the page says: its description tag, its main heading and the opening lines of its main text (never form fields). Settings → Privacy can leave the page text out or send only the site name. When a batch of tabs doesn't fit the model's context, the page text is shortened first, then dropped.
18The model sees a tab's title, its address, and up to 500 characters of what the page says: its description tag, its main heading and the opening lines of its main text (never form fields). Settings → Privacy can leave the page text out or send only the site name. When a batch of tabs doesn't fit the model's context, the page text is shortened first, then dropped.
18
19
host/diagonal-host.py+3−1
@@ -391,7 +391,9 @@ def op_organize(payload, opts):
391 result = validate.organize_from_topics(out, payload, owner)
391 result = validate.organize_from_topics(out, payload, owner)
392 except validate.ValidationError as e:
392 except validate.ValidationError as e:
393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
394+ chars = prompts.size(prompt)
395+ if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
394− chars = prompts.size(prompt) + name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
396+ chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
395 return result, chars, "topics"
397 return result, chars, "topics"
396
398
397
399
host/tests/test_host.py+6
@@ -343,6 +343,12 @@ class Organize(HostCase):
343 self.call("organize", {"items": self.ITEMS})
343 self.call("organize", {"items": self.ITEMS})
344 self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
344 self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
345
345
346+ def test_topics_only_when_asked_not_to_name(self):
347+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Travel")])
348+ r = self.call("organize", {"items": self.ITEMS, "nameGroups": False})
349+ self.assertTrue(r["ok"], r)
350+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
351+
346 def test_a_topic_of_one_tab_is_a_leftover(self):
352 def test_a_topic_of_one_tab_is_a_leftover(self):
347 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
353 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
348 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
354 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
src/background/engine.ts+36−1
@@ -50,7 +50,8 @@ export type Action =
50 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
50 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
51 | { type: "dirty"; groupId: number }
51 | { type: "dirty"; groupId: number }
52 | { type: "loose"; windowId: number }
52 | { type: "loose"; windowId: number }
53− | { type: "unpark"; tabId: number; groupId: number };
53+ | { type: "unpark"; tabId: number; groupId: number }
54+ | { type: "checkFit" };
54
55
55export interface Ctx {
56export interface Ctx {
56 now: number;
57 now: number;
@@ -105,6 +106,18 @@ const handlers: Handlers = {
105 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
106 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
106 if (before.groupId !== rec.groupId) {
107 if (before.groupId !== rec.groupId) {
107 left(s, before.groupId, ctx, out);
108 left(s, before.groupId, ctx, out);
109+ rec.fitPending = undefined;
110+ if (rec.groupId !== -1) {
111+ // Into a group: ours if the worker put it there, otherwise the user did and it stays.
112+ if (s.ownAdds[rec.id] !== undefined) {
113+ delete s.ownAdds[rec.id];
114+ rec.handPlaced = undefined;
115+ } else {
116+ rec.handPlaced = true;
117+ }
118+ } else {
119+ rec.handPlaced = undefined;
120+ }
108 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
121 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
109 else if (before.groupId !== -1) {
122 else if (before.groupId !== -1) {
110 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
123 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
@@ -122,6 +135,8 @@ const handlers: Handlers = {
122 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
135 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
123 return;
136 return;
124 }
137 }
138+ if (pathChanged && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
139+ if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
140 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
141 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
127 // A member finished loading: the naming loop may have been waiting for it.
142 // A member finished loading: the naming loop may have been waiting for it.
@@ -140,6 +155,8 @@ const handlers: Handlers = {
140 const rec = s.tabs[tabId];
155 const rec = s.tabs[tabId];
141 if (!rec) return;
156 if (!rec) return;
142 rec.lastActivatedAt = ctx.now;
157 rec.lastActivatedAt = ctx.now;
158+ // A tab waiting for its fit check is checked once the user has moved on from it.
159+ if (Object.values(s.tabs).some((t) => t.fitPending && t.id !== tabId)) out.push({ type: "checkFit" });
143 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
160 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
144 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
161 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
145 rec.parkedFrom = undefined;
162 rec.parkedFrom = undefined;
@@ -236,6 +253,21 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
236export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
253export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
237 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
254 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
238
255
256+/** Only groups Diagonal made and still names itself, and only tabs it put there, are checked for fit. */
257+export function fitCheckable(s: State, rec: TabRecord, settings: Settings): boolean {
258+ const g = s.groups[rec.groupId];
259+ return (
260+ settings.autoOrganize &&
261+ !!g &&
262+ g.managed &&
263+ (g.origin === "opener" || g.origin === "organize") &&
264+ !g.userNamed &&
265+ !rec.handPlaced &&
266+ !rec.pinned &&
267+ !isInternalUrl(rec.url)
268+ );
269+}
270+
239/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
271/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
240function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
272function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
241 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
273 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
@@ -305,6 +337,8 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
305 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
337 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
306 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
338 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
307 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
339 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
340+ if (before?.handPlaced) rec.handPlaced = true;
341+ if (before?.fitPending) rec.fitPending = true;
308 s.tabs[tab.id] = rec;
342 s.tabs[tab.id] = rec;
309 return rec;
343 return rec;
310}
344}
@@ -327,4 +361,5 @@ function prune(s: State, now: number): void {
327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
361 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
328 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
362 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
329 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
363 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
364+ for (const [id, at] of Object.entries(s.ownAdds)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownAdds[+id];
330}
365}
src/background/fit.tsnew file+122
@@ -0,0 +1,122 @@
1+import { isInternalUrl } from "../shared/url";
2+import { fitCheckable } from "./engine";
3+import { toItems, type Member } from "./naming";
4+import type { OrganizePayload, OrganizeResult } from "./organize";
5+import { ungroup, type Runtime } from "./runtime";
6+
7+/**
8+ * A tab in a group Diagonal made that moves on to an unrelated page leaves the group; auto-organize then
9+ * files it with its new topic. The check asks the host's topic question for the tab and a few groupmates:
10+ * the tab fits when it shares a topic with at least one of them.
11+ */
12+export const FIT_DELAY_MS = 2000;
13+export const FIT_SAMPLE = 5; // groupmates to compare with, most recently used first
14+const MIN_OTHERS = 2; // with fewer, one topic answer decides too much
15+
16+export type FitVerdict = "fits" | "leave" | "unsure";
17+
18+/** Index 0 is the tab being checked, the rest its groupmates. */
19+export function fitVerdict(result: OrganizeResult): FitVerdict {
20+ const withTab = result.groups.find((g) => g.members.includes(0));
21+ if (withTab && withTab.members.some((i) => i !== 0)) return "fits";
22+ // Only move it when its groupmates still agree with each other; otherwise the group itself is mixed.
23+ return result.groups.some((g) => !g.members.includes(0) && g.members.length >= 2) ? "leave" : "unsure";
24+}
25+
26+export class FitChecker {
27+ private timer: ReturnType<typeof setTimeout> | undefined;
28+ private running = false;
29+ private again = false;
30+
31+ constructor(private rt: Runtime) {}
32+
33+ touch(delayMs = FIT_DELAY_MS): void {
34+ if (this.timer) clearTimeout(this.timer);
35+ this.timer = setTimeout(() => {
36+ this.timer = undefined;
37+ void this.run();
38+ }, delayMs);
39+ }
40+
41+ async run(): Promise<void> {
42+ if (this.running) {
43+ this.again = true;
44+ return;
45+ }
46+ this.running = true;
47+ try {
48+ const pending = Object.values(this.rt.state().tabs).filter((t) => t.fitPending).map((t) => t.id);
49+ for (const id of pending) await this.check(id);
50+ } catch (e) {
51+ this.rt.log("fit check failed", e);
52+ } finally {
53+ this.running = false;
54+ if (this.again) {
55+ this.again = false;
56+ this.touch();
57+ }
58+ }
59+ }
60+
61+ private async check(tabId: number): Promise<void> {
62+ const rt = this.rt;
63+ const rec = rt.state().tabs[tabId];
64+ if (!rec?.fitPending) return;
65+ const tab = await chrome.tabs.get(tabId).catch(() => undefined);
66+ const done = () => {
67+ const live = rt.state().tabs[tabId];
68+ if (live) live.fitPending = undefined;
69+ rt.commit();
70+ };
71+ if (!tab || (tab.groupId ?? -1) !== rec.groupId || !fitCheckable(rt.state(), rec, rt.settings())) return done();
72+ // Still loading, or the user is reading it: the load or the next tab switch checks again.
73+ if (tab.status !== "complete" || tab.active) return;
74+ const pausedUntil = rt.state().host.pausedUntil;
75+ if (pausedUntil && pausedUntil > rt.now()) return;
76+
77+ const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
78+ .filter((t) => t.id !== tabId && t.status === "complete" && !isInternalUrl(t.url))
79+ .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
80+ .slice(0, FIT_SAMPLE);
81+ if (others.length < MIN_OTHERS) return done();
82+
83+ const s = rt.state();
84+ const toMember = (t: chrome.tabs.Tab): Member => ({
85+ id: t.id!,
86+ index: t.index,
87+ url: t.url ?? "",
88+ title: t.title ?? "",
89+ status: t.status,
90+ description: s.tabs[t.id!]?.description,
91+ lastActive: t.lastAccessed ?? 0,
92+ });
93+ const url = tab.url ?? "";
94+ const payload: OrganizePayload & { nameGroups: false } = {
95+ items: toItems([tab, ...others].map(toMember), rt.settings()),
96+ existingGroups: [],
97+ allowNew: true,
98+ maxGroups: 8,
99+ minGroupSize: 2,
100+ nameGroups: false, // only the topics matter here
101+ };
102+ const reply = await rt.host<OrganizeResult>("organize", payload);
103+
104+ const live = rt.state().tabs[tabId];
105+ // It moved on again (or out) while fm was thinking: that newer page gets its own check.
106+ if (!live || live.url !== url || live.groupId !== rec.groupId) return;
107+ if (!reply.ok) {
108+ rt.log("fit check", reply.error.code);
109+ return done();
110+ }
111+ const verdict = fitVerdict(reply.result);
112+ done();
113+ if (verdict !== "leave") return;
114+ const current = await chrome.tabs.get(tabId).catch(() => undefined);
115+ if (!current || current.active || current.groupId !== rec.groupId) return;
116+ rt.log("fit check: tab left its group", tabId, rec.groupId);
117+ live.organizedKey = undefined; // auto-organize looks at it again
118+ rt.state().ownUngroups[tabId] = rt.now();
119+ rt.commit();
120+ await ungroup(rt, [tabId]);
121+ }
122+}
src/background/index.ts+7
@@ -3,6 +3,7 @@ import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type Tab
3import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
3import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6+import { FitChecker } from "./fit";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { withDefaults, type Settings } from "./settings";
8import { withDefaults, type Settings } from "./settings";
8import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
@@ -239,6 +240,7 @@ async function scheduleAutoFallback(when: number): Promise<void> {
239}
240}
240
241
241const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
242const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
243+const fitChecker = new FitChecker(rt);
242
244
243// ----- engine glue -----------------------------------------------------------------------------
245// ----- engine glue -----------------------------------------------------------------------------
244
246
@@ -327,6 +329,9 @@ async function run(a: Action): Promise<void> {
327 case "dirty":
329 case "dirty":
328 naming.touch(a.groupId);
330 naming.touch(a.groupId);
329 break;
331 break;
332+ case "checkFit":
333+ fitChecker.touch();
334+ break;
330 }
335 }
331}
336}
332
337
@@ -365,6 +370,7 @@ async function reconcile(): Promise<void> {
365 parkedFrom: prev?.parkedFrom,
370 parkedFrom: prev?.parkedFrom,
366 keepLoose: prev?.keepLoose,
371 keepLoose: prev?.keepLoose,
367 organizedKey: prev?.organizedKey,
372 organizedKey: prev?.organizedKey,
373+ handPlaced: prev?.handPlaced,
368 createdAt: prev?.createdAt ?? now,
374 createdAt: prev?.createdAt ?? now,
369 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
375 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
370 };
376 };
@@ -393,6 +399,7 @@ async function reconcile(): Promise<void> {
393 state.pendingCreates = [];
399 state.pendingCreates = [];
394 state.ownWrites = {};
400 state.ownWrites = {};
395 state.ownUngroups = {};
401 state.ownUngroups = {};
402+ state.ownAdds = {};
396 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
403 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
397 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
404 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
398 commit();
405 commit();
src/background/runtime.ts+3
@@ -68,6 +68,7 @@ export async function createManagedGroup(
68): Promise<number | undefined> {
68): Promise<number | undefined> {
69 const s = rt.state();
69 const s = rt.state();
70 s.pendingCreates.push({ windowId, origin, at: rt.now() });
70 s.pendingCreates.push({ windowId, origin, at: rt.now() });
71+ for (const id of tabIds) s.ownAdds[id] = rt.now();
71 let groupId: number;
72 let groupId: number;
72 try {
73 try {
73 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
74 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
@@ -86,6 +87,8 @@ export async function createManagedGroup(
86}
87}
87
88
88export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
89export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
90+ const s = rt.state();
91+ for (const id of tabIds) s.ownAdds[id] = rt.now();
89 try {
92 try {
90 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
93 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
91 return true;
94 return true;
src/background/state.ts+6
@@ -20,6 +20,8 @@ export interface TabRecord {
20 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
20 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
21 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
21 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
22 organizedKey?: string; // page + title when auto-organize last considered this tab
22 organizedKey?: string; // page + title when auto-organize last considered this tab
23+ handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
24+ fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
23}
25}
24
26
25export interface GroupRecord {
27export interface GroupRecord {
@@ -120,6 +122,8 @@ export interface State {
120 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
122 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
121 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
123 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
122 ownUngroups: Record<number, number>;
124 ownUngroups: Record<number, number>;
125+ /** Tabs the worker itself is adding to a group: joining it is not the user's choice. */
126+ ownAdds: Record<number, number>;
123 lastSweep?: { at: number; moves: SweepMove[] };
127 lastSweep?: { at: number; moves: SweepMove[] };
124 lastOrganize?: { at: number; previous: Record<number, number> };
128 lastOrganize?: { at: number; previous: Record<number, number> };
125 tidyCandidates?: number;
129 tidyCandidates?: number;
@@ -134,6 +138,7 @@ export const emptyState = (): State => ({
134 ownWrites: {},
138 ownWrites: {},
135 pendingCreates: [],
139 pendingCreates: [],
136 ownUngroups: {},
140 ownUngroups: {},
141+ ownAdds: {},
137});
142});
138
143
139/** Accept whatever was stored and bring it to the current shape. */
144/** Accept whatever was stored and bring it to the current shape. */
@@ -154,6 +159,7 @@ export function migrate(raw: unknown): State {
154 ownWrites: r.ownWrites ?? {},
159 ownWrites: r.ownWrites ?? {},
155 pendingCreates: r.pendingCreates ?? [],
160 pendingCreates: r.pendingCreates ?? [],
156 ownUngroups: r.ownUngroups ?? {},
161 ownUngroups: r.ownUngroups ?? {},
162+ ownAdds: r.ownAdds ?? {},
157 };
163 };
158}
164}
159
165
test/e2e/smoke.mjs+27
@@ -47,6 +47,8 @@ const pages = {
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
50+ "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51+ "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
50};
52};
51const server = createServer((req, res) => {
53const server = createServer((req, res) => {
52 const p = pages[req.url.split("?")[0]];
54 const p = pages[req.url.split("?")[0]];
@@ -186,6 +188,31 @@ try {
186 const pasta = await tabIn(win2, "/pasta");
188 const pasta = await tabIn(win2, "/pasta");
187 check("an auto-made group closed down to one tab dissolves, and the tab is not regrouped alone", !!gone && pasta.groupId === -1, JSON.stringify({ gone, pasta: pasta.groupId }));
189 check("an auto-made group closed down to one tab dissolves, and the tab is not regrouped alone", !!gone && pasta.groupId === -1, JSON.stringify({ gone, pasta: pasta.groupId }));
188
190
191+ // 6b. A grouped tab that moves on to an unrelated page leaves its group and joins a better one.
192+ const win3 = await sw.evaluate(async (b) => {
193+ const w = await chrome.windows.create({ url: `${b}/flight` });
194+ for (const p of ["travel", "hotel", "pasta", "cook"]) await chrome.tabs.create({ windowId: w.id, url: `${b}/${p}` });
195+ return w.id;
196+ }, base);
197+ const sorted3 = await until(async () => {
198+ const t = await titlesIn(win3);
199+ return t.split("|").length === 2 ? t : undefined;
200+ }, 25000);
201+ const flight = await tabIn(win3, "/flight");
202+ const lisbonGroup = flight.groupId;
203+ const pastaGroup3 = (await tabIn(win3, "/pasta")).groupId;
204+ await sw.evaluate(async ([id, url]) => chrome.tabs.update(id, { url }), [flight.id, `${base}/risotto`]);
205+ const moved = await until(async () => {
206+ const t = await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id);
207+ return t.groupId === pastaGroup3 ? t : undefined;
208+ }, 25000);
209+ const lisbonLeft = await sw.evaluate(async (g) => (await chrome.tabs.query({ groupId: g })).length, lisbonGroup);
210+ check(
211+ "a grouped tab that moves to an unrelated page leaves for the matching group",
212+ !!sorted3 && !!moved && lisbonLeft === 2,
213+ JSON.stringify({ sorted3, now: (await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id)).groupId, lisbonGroup, pastaGroup3, lisbonLeft }),
214+ );
215+
189 // 7. Popup renders the healthy state.
216 // 7. Popup renders the healthy state.
190 await popup.setViewportSize({ width: 360, height: 560 });
217 await popup.setViewportSize({ width: 360, height: 560 });
191 await popup.reload();
218 await popup.reload();
test/engine.test.ts+59−1
@@ -167,7 +167,10 @@ describe("dirty triggers", () => {
167
167
168 it("title change and path change mark the group dirty", () => {
168 it("title change and path change mark the group dirty", () => {
169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
170− expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
170+ expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([
171+ { type: "checkFit" },
172+ { type: "dirty", groupId: 10 },
173+ ]);
171 });
174 });
172
175
173 it("reload, fragment and query changes do not", () => {
176 it("reload, fragment and query changes do not", () => {
@@ -252,3 +255,58 @@ describe("auto-organize triggers", () => {
252 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
255 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
253 });
256 });
254});
257});
258+
259+describe("fit check", () => {
260+ const group = () => stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
261+ const moveOn = (s = group(), over = {}) =>
262+ step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story", ...over }) }, ctx());
263+
264+ it("a tab in a Diagonal group that goes to a new page is checked once it has loaded", () => {
265+ const loading = moveOn(group(), { status: "loading" });
266+ expect(loading.state.tabs[1].fitPending).toBe(true);
267+ expect(loading.actions).not.toContainEqual({ type: "checkFit" });
268+ const loaded = step(loading.state, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx());
269+ expect(loaded.actions).toContainEqual({ type: "checkFit" });
270+ });
271+
272+ it("switching to another tab checks the pending one", () => {
273+ const { state } = moveOn(group(), { status: "loading" });
274+ expect(step(state, { type: "tabActivated", tabId: 2 }, ctx()).actions).toContainEqual({ type: "checkFit" });
275+ expect(step(state, { type: "tabActivated", tabId: 1 }, ctx()).actions).not.toContainEqual({ type: "checkFit" });
276+ });
277+
278+ it("leaves groups you named and groups you made alone", () => {
279+ const named = group();
280+ named.groups[10].userNamed = true;
281+ expect(moveOn(named).state.tabs[1].fitPending).toBeUndefined();
282+ const yours = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "user" }]);
283+ expect(moveOn(yours).state.tabs[1].fitPending).toBeUndefined();
284+ expect(moveOn(group(), {}).state.tabs[1].fitPending).toBe(true);
285+ const off = step(group(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx(0, { autoOrganize: false }));
286+ expect(off.state.tabs[1].fitPending).toBeUndefined();
287+ });
288+
289+ it("a tab you dragged into a group stays there", () => {
290+ const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
291+ const dragged = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
292+ expect(dragged.state.tabs[1].handPlaced).toBe(true);
293+ expect(moveOn(dragged.state).state.tabs[1].fitPending).toBeUndefined();
294+ });
295+
296+ it("a tab Diagonal put in the group is still checked", () => {
297+ const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
298+ s.ownAdds[1] = 1_000_000;
299+ const added = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
300+ expect(added.state.tabs[1].handPlaced).toBeUndefined();
301+ expect(added.state.ownAdds[1]).toBeUndefined();
302+ expect(moveOn(added.state).state.tabs[1].fitPending).toBe(true);
303+ });
304+
305+ it("leaving the group clears both marks", () => {
306+ const pending = moveOn(group(), { status: "loading" }).state;
307+ pending.tabs[1].handPlaced = true;
308+ const out = step(pending, { type: "tabUpdated", tab: tab(1, { url: "https://news.example/story" }) }, ctx());
309+ expect(out.state.tabs[1].fitPending).toBeUndefined();
310+ expect(out.state.tabs[1].handPlaced).toBeUndefined();
311+ });
312+});
test/fit.test.tsnew file+18
@@ -0,0 +1,18 @@
1+import { describe, expect, it } from "vitest";
2+import { fitVerdict } from "../src/background/fit";
3+
4+const g = (...members: number[]) => ({ title: "", emoji: "", color: "", members });
5+
6+describe("fit verdict (tab 0 against its groupmates)", () => {
7+ it("fits when it shares a topic with any groupmate", () => {
8+ expect(fitVerdict({ groups: [g(0, 2), g(1, 3)], leftovers: [] })).toBe("fits");
9+ });
10+
11+ it("leaves when its groupmates agree and it matches none of them", () => {
12+ expect(fitVerdict({ groups: [g(1, 2, 3)], leftovers: [0] })).toBe("leave");
13+ });
14+
15+ it("is unsure when the groupmates don't agree with each other either", () => {
16+ expect(fitVerdict({ groups: [], leftovers: [0, 1, 2] })).toBe("unsure");
17+ });
18+});