on master help sign in
Move a tab out of its group when it goes to an unrelated page

When a tab in a group Diagonal made (opener or organize, not renamed by hand) loads a new page, it is marked for a fit check. Once it has loaded and the user has switched away from it, the worker asks the host's topic question for the tab and up to five groupmates. If the groupmates still share a topic and the tab shares none with them, the tab is ungrouped as Diagonal's own move, and auto-organize files it with its new topic. Tabs the user dragged into a group are never checked: the worker now records its own group additions (ownAdds), like its own ungroups, so any other join is the user's. The host skips naming new groups when the payload says nameGroups: false. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commit8a02329012f1b3406bb81d8a0c7331a371a7df82 authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 22:09 (2 weeks ago) parent5c74022
11 files changed+290−5
README.md+3−2 host/diagonal-host.py+3−1 host/tests/test_host.py+6 src/background/engine.ts+36−1 src/background/fit.ts+122 src/background/index.ts+7 src/background/runtime.ts+3 src/background/state.ts+6 test/e2e/smoke.mjs+27 test/engine.test.ts+59−1 test/fit.test.ts+18
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
10102. **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.
11113. **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.
12124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
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.
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.
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.
1415
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.
1617
1718The 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.
1819
host/diagonal-host.py+3−1
@@ -391,7 +391,9 @@ def op_organize(payload, opts):
391391 result = validate.organize_from_topics(out, payload, owner)
392392 except validate.ValidationError as e:
393393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
394− chars = prompts.size(prompt) + name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
394+ chars = prompts.size(prompt)
395+ if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
396+ chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
395397 return result, chars, "topics"
396398
397399
host/tests/test_host.py+6
@@ -343,6 +343,12 @@ class Organize(HostCase):
343343 self.call("organize", {"items": self.ITEMS})
344344 self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
345345
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+
346352 def test_a_topic_of_one_tab_is_a_leftover(self):
347353 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
348354 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
src/background/engine.ts+36−1
@@ -50,7 +50,8 @@ export type Action =
5050 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
5151 | { type: "dirty"; groupId: number }
5252 | { type: "loose"; windowId: number }
53− | { type: "unpark"; tabId: number; groupId: number };
53+ | { type: "unpark"; tabId: number; groupId: number }
54+ | { type: "checkFit" };
5455
5556export interface Ctx {
5657 now: number;
@@ -105,6 +106,18 @@ const handlers: Handlers = {
105106 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
106107 if (before.groupId !== rec.groupId) {
107108 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+ }
108121 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
109122 else if (before.groupId !== -1) {
110123 // 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 = {
122135 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
123136 return;
124137 }
138+ if (pathChanged && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
139+ if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
125140 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
126141 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
127142 // A member finished loading: the naming loop may have been waiting for it.
@@ -140,6 +155,8 @@ const handlers: Handlers = {
140155 const rec = s.tabs[tabId];
141156 if (!rec) return;
142157 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" });
143160 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
144161 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
145162 rec.parkedFrom = undefined;
@@ -236,6 +253,21 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
236253export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
237254 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
238255
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+
239271/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
240272function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
241273 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 {
305337 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
306338 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
307339 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
340+ if (before?.handPlaced) rec.handPlaced = true;
341+ if (before?.fitPending) rec.fitPending = true;
308342 s.tabs[tab.id] = rec;
309343 return rec;
310344}
@@ -327,4 +361,5 @@ function prune(s: State, now: number): void {
327361 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
328362 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
329363 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];
330365}
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
33import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
44import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
55import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6+import { FitChecker } from "./fit";
67import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
78import { withDefaults, type Settings } from "./settings";
89import { 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> {
239240}
240241
241242const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
243+const fitChecker = new FitChecker(rt);
242244
243245// ----- engine glue -----------------------------------------------------------------------------
244246
@@ -327,6 +329,9 @@ async function run(a: Action): Promise<void> {
327329 case "dirty":
328330 naming.touch(a.groupId);
329331 break;
332+ case "checkFit":
333+ fitChecker.touch();
334+ break;
330335 }
331336}
332337
@@ -365,6 +370,7 @@ async function reconcile(): Promise<void> {
365370 parkedFrom: prev?.parkedFrom,
366371 keepLoose: prev?.keepLoose,
367372 organizedKey: prev?.organizedKey,
373+ handPlaced: prev?.handPlaced,
368374 createdAt: prev?.createdAt ?? now,
369375 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
370376 };
@@ -393,6 +399,7 @@ async function reconcile(): Promise<void> {
393399 state.pendingCreates = [];
394400 state.ownWrites = {};
395401 state.ownUngroups = {};
402+ state.ownAdds = {};
396403 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
397404 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
398405 commit();
src/background/runtime.ts+3
@@ -68,6 +68,7 @@ export async function createManagedGroup(
6868): Promise<number | undefined> {
6969 const s = rt.state();
7070 s.pendingCreates.push({ windowId, origin, at: rt.now() });
71+ for (const id of tabIds) s.ownAdds[id] = rt.now();
7172 let groupId: number;
7273 try {
7374 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
@@ -86,6 +87,8 @@ export async function createManagedGroup(
8687}
8788
8889export 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();
8992 try {
9093 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
9194 return true;
src/background/state.ts+6
@@ -20,6 +20,8 @@ export interface TabRecord {
2020 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
2121 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2222 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)
2325}
2426
2527export interface GroupRecord {
@@ -120,6 +122,8 @@ export interface State {
120122 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
121123 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
122124 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>;
123127 lastSweep?: { at: number; moves: SweepMove[] };
124128 lastOrganize?: { at: number; previous: Record<number, number> };
125129 tidyCandidates?: number;
@@ -134,6 +138,7 @@ export const emptyState = (): State => ({
134138 ownWrites: {},
135139 pendingCreates: [],
136140 ownUngroups: {},
141+ ownAdds: {},
137142});
138143
139144/** Accept whatever was stored and bring it to the current shape. */
@@ -154,6 +159,7 @@ export function migrate(raw: unknown): State {
154159 ownWrites: r.ownWrites ?? {},
155160 pendingCreates: r.pendingCreates ?? [],
156161 ownUngroups: r.ownUngroups ?? {},
162+ ownAdds: r.ownAdds ?? {},
157163 };
158164}
159165
test/e2e/smoke.mjs+27
@@ -47,6 +47,8 @@ const pages = {
4747 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
4848 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
4949 "/misc": ["Something else", "Unrelated page.", ""],
50+ "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51+ "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
5052};
5153const server = createServer((req, res) => {
5254 const p = pages[req.url.split("?")[0]];
@@ -186,6 +188,31 @@ try {
186188 const pasta = await tabIn(win2, "/pasta");
187189 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 }));
188190
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+
189216 // 7. Popup renders the healthy state.
190217 await popup.setViewportSize({ width: 360, height: 560 });
191218 await popup.reload();
test/engine.test.ts+59−1
@@ -167,7 +167,10 @@ describe("dirty triggers", () => {
167167
168168 it("title change and path change mark the group dirty", () => {
169169 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+ ]);
171174 });
172175
173176 it("reload, fragment and query changes do not", () => {
@@ -252,3 +255,58 @@ describe("auto-organize triggers", () => {
252255 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
253256 });
254257});
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+});