on master help sign in
Organize tabs automatically, with no shortcuts or buttons needed

Loose tabs are sorted into topic groups once a window has been quiet for 8 s: they join one of Diagonal's groups, pair up with other loose tabs, or stay loose. A tab the user takes out of a group stays out until it goes to another page. - Remove all keyboard commands from the manifest - Tidy parks automatically by default; opening a parked tab unparks it - Topic groups dissolve at one tab like opener groups do - Naming and organize share one model queue - Settings for auto-organize and its delay; unit and smoke tests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdKDAYoqmGehyHsV42LhJW

commit98ea35dec97f93035f02760f289f7e28c42f0633 authorClaude <noreply@anthropic.com> dateWed 23 Sep 2026, 16:43 (2 weeks ago) parentea3b548
15 files changed+507−92
README.md+10−16 manifest.json+1−20 src/background/engine.ts+42−7 src/background/index.ts+41−28 src/background/organize.ts+136−1 src/background/settings.ts+6−1 src/background/state.ts+6 src/background/tidy.ts+2−1 src/options/options.ts+6−4 src/popup/popup.html+1−1 src/popup/popup.ts+1−1 test/auto.test.ts+146 test/e2e/smoke.mjs+34−5 test/engine.test.ts+74−6 test/url.test.ts+1−1
README.md+10−16
@@ -2,12 +2,15 @@
22
33Dia-style tab groups for Brave on macOS, named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
44
5−What it does (v1):
5+You open and close tabs as usual. Diagonal does the rest on its own; there are no shortcuts and nothing to press.
66
7−1. **Opener grouping.** Cmd-click a link from an ungrouped, unpinned tab and both tabs become a group, titled with the site's hostname until the model names it. An opener group that shrinks to one tab dissolves.
8−2. **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.
9−3. **Organize this window** (toolbar or Alt+Shift+G). Clusters ungrouped tabs into topic groups in batches of 18. Undo is available for an hour.
10−4. **Tidy sweep.** Tabs idle for 24 h move to a collapsed grey "Parked" group (Ask mode by default). After another 48 h they are archived and closed, with restore from the popup.
7+1. **Opener grouping.** Cmd-click a link from an ungrouped, unpinned tab and both tabs become a group, titled with the site's hostname until the model names it.
8+2. **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.
9+3. **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.
10+4. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
11+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.
12+
13+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.
1114
1215The model sees a tab's title, address and `<meta name="description">`, and nothing else.
1316
@@ -57,16 +60,6 @@ programs.diagonal.enable = true;
5760
5861Open Diagonal's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `diagonal-host --selftest` runs the same checks.
5962
60−## Keyboard shortcuts
61−
62−| Command | Default |
63−| --- | --- |
64−| Organize this window | Alt+Shift+G |
65−| Name the current group now | Alt+Shift+N |
66−| Tidy now | unset |
67−
68−Change them at `brave://extensions/shortcuts`.
69−
7063## Develop
7164
7265```sh
@@ -94,7 +87,8 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
9487
9588## Where this differs from the spec, and why
9689
97−- **No "new tab in group" shortcut.** The spec's Alt+Cmd+T command was dropped: grouping happens on its own through opener grouping, naming and Organize.
90+- **Fully automatic.** The spec's keyboard commands are gone (new tab in group, organize, rename, tidy now). Organize runs on its own for loose tabs, the tidy sweep parks without asking by default (Ask mode is still a setting), and topic groups dissolve at one tab like opener groups do.
91+- **Model calls are one at a time.** Naming and auto-organize share a queue, so they never run the model in parallel.
9892- **Query strings.** The spec keeps the query only when the path is empty, but also wants `youtube.com/watch?v=…` kept. Diagonal keeps the whole query when the path is empty and otherwise keeps only content keys (`v`, `q`, `query`, `search_query`, `search`, `s`, `k`, `id`, `p`, `list`, `page`), dropping `utm_*` and similar.
9993- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
10094- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
manifest.json+1−20
@@ -49,24 +49,5 @@
4949 "48": "icons/48.png",
5050 "128": "icons/128.png"
5151 },
52− "options_page": "options.html",
53− "commands": {
54− "organize-window": {
55− "suggested_key": {
56− "default": "Alt+Shift+G",
57− "mac": "Alt+Shift+G"
58− },
59− "description": "Organize ungrouped tabs in this window"
60− },
61− "rename-current-group": {
62− "suggested_key": {
63− "default": "Alt+Shift+N",
64− "mac": "Alt+Shift+N"
65− },
66− "description": "Re-name the current tab group now"
67− },
68− "tidy-now": {
69− "description": "Run the tidy sweep now"
70− }
71− }
52+ "options_page": "options.html"
7253}
src/background/engine.ts+42−7
@@ -48,7 +48,9 @@ export type Action =
4848 | { type: "addToGroup"; tabIds: number[]; groupId: number }
4949 | { type: "ungroup"; tabIds: number[] }
5050 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
51− | { type: "dirty"; groupId: number };
51+ | { type: "dirty"; groupId: number }
52+ | { type: "loose"; windowId: number }
53+ | { type: "unpark"; tabId: number; groupId: number };
5254
5355export interface Ctx {
5456 now: number;
@@ -84,7 +86,9 @@ const handlers: Handlers = {
8486 if (tab.incognito) return;
8587 const rec = upsertTab(s, tab, ctx.now);
8688 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
89+ const before = out.length;
8790 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
91+ if (out.length === before) loose(rec, ctx, out);
8892 },
8993
9094 tabUpdated(s, { tab }, ctx, out) {
@@ -93,16 +97,31 @@ const handlers: Handlers = {
9397 const rec = upsertTab(s, tab, ctx.now);
9498 if (!before) {
9599 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
100+ else loose(rec, ctx, out);
96101 return;
97102 }
103+ const titleChanged = before.title !== rec.title;
104+ const pathChanged = pathKey(before.url) !== pathKey(rec.url);
105+ if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
98106 if (before.groupId !== rec.groupId) {
99107 left(s, before.groupId, ctx, out);
100108 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
109+ 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.
111+ if (s.ownUngroups[rec.id] !== undefined) {
112+ delete s.ownUngroups[rec.id];
113+ loose(rec, ctx, out);
114+ } else {
115+ rec.keepLoose = pathKey(rec.url);
116+ }
117+ }
118+ return;
119+ }
120+ if (rec.groupId === -1) {
121+ const loaded = before.status !== "complete" && rec.status === "complete";
122+ if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
101123 return;
102124 }
103− if (rec.groupId === -1) return;
104− const titleChanged = before.title !== rec.title;
105− const pathChanged = pathKey(before.url) !== pathKey(rec.url);
106125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
107126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
108127 // A member finished loading: the naming loop may have been waiting for it.
@@ -117,9 +136,15 @@ const handlers: Handlers = {
117136 left(s, rec.groupId, ctx, out);
118137 },
119138
120− tabActivated(s, { tabId }, ctx) {
139+ tabActivated(s, { tabId }, ctx, out) {
121140 const rec = s.tabs[tabId];
122− if (rec) rec.lastActivatedAt = ctx.now;
141+ if (!rec) return;
142+ rec.lastActivatedAt = ctx.now;
143+ // 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") {
145+ rec.parkedFrom = undefined;
146+ out.push({ type: "unpark", tabId, groupId: rec.groupId });
147+ }
123148 },
124149
125150 groupCreated(s, { group }, ctx, out) {
@@ -207,8 +232,15 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
207232 });
208233}
209234
235+/** Groups Diagonal made (opener or topic) go away when one tab is left; the Parked group and yours stay. */
210236export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
211− g.managed && g.origin === "opener" && settings.dissolveSingletons;
237+ g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
238+
239+/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
240+function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
241+ if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
242+ out.push({ type: "loose", windowId: rec.windowId });
243+}
212244
213245/** A tab left `groupId`: the group changed, and an opener group may now be a singleton. */
214246function left(s: State, groupId: number, ctx: Ctx, out: Action[]): void {
@@ -271,6 +303,8 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
271303 else if (before?.openerTabId !== undefined) rec.openerTabId = before.openerTabId;
272304 // A description belongs to a page: keep it only while the tab stays on that page.
273305 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
306+ if (before?.keepLoose) rec.keepLoose = before.keepLoose;
307+ if (before?.organizedKey) rec.organizedKey = before.organizedKey;
274308 s.tabs[tab.id] = rec;
275309 return rec;
276310}
@@ -292,4 +326,5 @@ export function pathKey(url: string | undefined): string {
292326function prune(s: State, now: number): void {
293327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
294328 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];
295330}
src/background/index.ts+41−28
@@ -2,7 +2,7 @@ import { stripTitle } from "../shared/label";
22import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
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";
5−import { organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5+import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
66import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
77import { withDefaults, type Settings } from "./settings";
88import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
@@ -13,6 +13,7 @@ import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, und
1313const ALARM_NAMING = "naming-fallback";
1414const ALARM_TIDY = "tidy-sweep";
1515const ALARM_HOST = "host-retry";
16+const ALARM_AUTO = "auto-organize";
1617const HOST_RETRY_MS = 600_000;
1718
1819// ----- state cache ------------------------------------------------------------------------------
@@ -65,11 +66,21 @@ function logError(where: string, e: unknown): void {
6566
6667// ----- host ------------------------------------------------------------------------------------
6768
69+let modelQueue: Promise<unknown> = Promise.resolve();
70+
71+/** Naming and organizing both run the model: one call at a time, so they never compete for it. */
72+function oneModelCall<T>(fn: () => Promise<T>): Promise<T> {
73+ const next = modelQueue.then(fn, fn);
74+ modelQueue = next.catch(() => undefined);
75+ return next;
76+}
77+
6878async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}): Promise<HostReply<T>> {
6979 const opts: HostOpts = { model: settings.model, timeoutMs: settings.timeoutMs, emoji: settings.emoji, debug: settings.debugLog, ...extra };
7080 busy(true);
7181 try {
72− const reply = await callHost<T>(chromeSender, op, payload, opts);
82+ const call = () => callHost<T>(chromeSender, op, payload, opts);
83+ const reply = op === "ping" ? await call() : await oneModelCall(call);
7384 const h = state.host;
7485 if (reply.ok) {
7586 h.lastOkAt = Date.now();
@@ -122,6 +133,7 @@ async function ping(): Promise<HostReply<PingResult>> {
122133 h.lastError = undefined;
123134 chrome.alarms.clear(ALARM_HOST);
124135 void naming.sweepDirty();
136+ void autoOrganizer.sweep();
125137 }
126138 }
127139 commit();
@@ -213,6 +225,15 @@ const rt: Runtime = {
213225 log,
214226};
215227
228+async function scheduleAutoFallback(when: number): Promise<void> {
229+ const at = Math.max(when, Date.now() + 30_000);
230+ const existing = await chrome.alarms.get(ALARM_AUTO);
231+ if (existing && existing.scheduledTime <= at && existing.scheduledTime > Date.now()) return;
232+ await chrome.alarms.create(ALARM_AUTO, { when: at });
233+}
234+
235+const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
236+
216237// ----- engine glue -----------------------------------------------------------------------------
217238
218239let chain: Promise<unknown> = Promise.resolve();
@@ -274,8 +295,20 @@ async function run(a: Action): Promise<void> {
274295 await addToGroup(rt, a.tabIds, a.groupId);
275296 break;
276297 case "ungroup":
298+ for (const id of a.tabIds) state.ownUngroups[id] = Date.now();
277299 await ungroup(rt, a.tabIds);
278300 break;
301+ case "unpark": {
302+ const rec = state.tabs[a.tabId];
303+ if (rec) rec.organizedKey = undefined;
304+ state.ownUngroups[a.tabId] = Date.now();
305+ await ungroup(rt, [a.tabId]);
306+ if ((await chrome.tabs.query({ groupId: a.groupId }).catch(() => [])).length) await updateGroup(rt, a.groupId, { collapsed: true });
307+ break;
308+ }
309+ case "loose":
310+ autoOrganizer.touch(a.windowId);
311+ break;
279312 case "scheduleDissolve":
280313 // Re-checked after the delay so Cmd+Shift+T right after a close does not flicker.
281314 setTimeout(() => {
@@ -324,6 +357,8 @@ async function reconcile(): Promise<void> {
324357 openerTabId: t.openerTabId,
325358 description: prev?.description,
326359 parkedFrom: prev?.parkedFrom,
360+ keepLoose: prev?.keepLoose,
361+ organizedKey: prev?.organizedKey,
327362 createdAt: prev?.createdAt ?? now,
328363 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
329364 };
@@ -351,6 +386,7 @@ async function reconcile(): Promise<void> {
351386 state.groups = nextGroups;
352387 state.pendingCreates = [];
353388 state.ownWrites = {};
389+ state.ownUngroups = {};
354390 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
355391 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
356392 commit();
@@ -373,6 +409,7 @@ async function boot(reason: "startup" | "installed"): Promise<void> {
373409 await ping();
374410 await naming.sweepDirty();
375411 if (reason === "startup") void runSweep(rt).catch((e) => logError("tidy", e));
412+ await autoOrganizer.sweep();
376413 refreshBadge();
377414}
378415
@@ -425,6 +462,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
425462 void (async () => {
426463 await ready;
427464 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
465+ else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
428466 else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
429467 else if (alarm.name === ALARM_HOST) {
430468 const r = await ping();
@@ -441,6 +479,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
441479 settings = withDefaults(changes.settings.newValue);
442480 if (before.emoji !== settings.emoji) await rewriteTitles();
443481 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
482+ if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
444483 refreshBadge();
445484 })();
446485});
@@ -453,13 +492,6 @@ async function rewriteTitles(): Promise<void> {
453492 }
454493}
455494
456−// ----- commands --------------------------------------------------------------------------------
457−
458−async function activeTab(): Promise<chrome.tabs.Tab | undefined> {
459− const [t] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
460− return t;
461−}
462−
463495async function nameGroupNow(groupId: number): Promise<void> {
464496 const g = state.groups[groupId];
465497 if (!g) return;
@@ -470,25 +502,6 @@ async function nameGroupNow(groupId: number): Promise<void> {
470502 await naming.nameNow(groupId);
471503}
472504
473−chrome.commands.onCommand.addListener((command) => {
474− void (async () => {
475− await ready;
476− try {
477− if (command === "organize-window") {
478− const t = await activeTab();
479− if (t) await organizeWindow(rt, t.windowId);
480− } else if (command === "rename-current-group") {
481− const t = await activeTab();
482− if (t && (t.groupId ?? -1) !== -1) await nameGroupNow(t.groupId);
483− } else if (command === "tidy-now") {
484− await runSweep(rt, true);
485− }
486− } catch (e) {
487− logError(`command ${command}`, e);
488− }
489− })();
490−});
491−
492505// ----- messages (content script, popup, options) -----------------------------------------------
493506
494507type Msg = { kind?: string; cmd?: string; [k: string]: any };
src/background/organize.ts+136−1
@@ -1,6 +1,7 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
22import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4+import { pathKey } from "./engine";
45import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
56import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
67import { markDirty, type State } from "./state";
@@ -227,7 +228,7 @@ export async function organizeWindow(rt: Runtime, windowId: number): Promise<Org
227228 return report;
228229}
229230
230−async function applyPlan(
231+export async function applyPlan(
231232 rt: Runtime,
232233 plan: Plan,
233234 windowId: number,
@@ -294,4 +295,138 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
294295 return restored;
295296}
296297
298+/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
299+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
300+
301+export const AUTO_RETRY_MS = 300_000;
302+export const AUTO_CONTINUE_MS = 1_500;
303+
304+export interface AutoDeps {
305+ /** Wake the worker later even if it is suspended before the debounce timer fires. */
306+ scheduleFallback(when: number): void;
307+}
308+
309+/**
310+ * Section 3 without the button: loose tabs are organized on their own once a window has been
311+ * quiet for `autoOrganizeDelayMs`. Each run sends one batch, new or changed tabs first, together
312+ * with the window's other loose tabs and Diagonal's groups, so a new tab can join an existing group
313+ * or pair up with a tab that was left over earlier. A tab the model leaves loose is not sent again
314+ * until its page or title changes or another tab arrives to pair it with.
315+ */
316+export class AutoOrganizer {
317+ private timers = new Map<number, ReturnType<typeof setTimeout>>();
318+ private running = false;
319+ private queued = new Set<number>();
320+
321+ constructor(
322+ private rt: Runtime,
323+ private deps: AutoDeps,
324+ ) {}
325+
326+ touch(windowId: number, delayMs = this.rt.settings().autoOrganizeDelayMs): void {
327+ if (!this.rt.settings().autoOrganize) return;
328+ const old = this.timers.get(windowId);
329+ if (old) clearTimeout(old);
330+ this.timers.set(
331+ windowId,
332+ setTimeout(() => {
333+ this.timers.delete(windowId);
334+ void this.run(windowId);
335+ }, delayMs),
336+ );
337+ this.deps.scheduleFallback(this.rt.now() + delayMs + 60_000);
338+ }
339+
340+ /** Every window, now: on boot, when the host recovers, and from the fallback alarm. */
341+ async sweep(): Promise<void> {
342+ if (!this.rt.settings().autoOrganize) return;
343+ const windows = await chrome.windows.getAll({ windowTypes: ["normal"] }).catch(() => []);
344+ for (const w of windows) if (w.id !== undefined && !w.incognito) await this.run(w.id);
345+ }
346+
347+ async run(windowId: number): Promise<void> {
348+ if (this.running) {
349+ this.queued.add(windowId);
350+ return;
351+ }
352+ this.running = true;
353+ try {
354+ await this.once(windowId);
355+ } catch (e) {
356+ this.rt.log("auto-organize failed", e);
357+ } finally {
358+ this.running = false;
359+ const next = this.queued.values().next();
360+ if (!next.done) {
361+ this.queued.delete(next.value);
362+ void this.run(next.value);
363+ }
364+ }
365+ }
366+
367+ private async once(windowId: number): Promise<void> {
368+ const rt = this.rt;
369+ const settings = rt.settings();
370+ if (!settings.autoOrganize) return;
371+ const pausedUntil = rt.state().host.pausedUntil;
372+ if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
373+ const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
374+ const s = rt.state();
375+ // Still-loading tabs are skipped here; finishing the load touches the window again.
376+ const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
377+ const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
378+ if (!fresh.length) return;
379+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
380+ if (loose.length < 2 && !existingIds.length) return; // one loose tab and nothing to join: wait for company
381+ const toMember = (t: chrome.tabs.Tab): Member => ({
382+ id: t.id!,
383+ index: t.index,
384+ url: t.url ?? "",
385+ title: t.title ?? "",
386+ status: t.status,
387+ description: s.tabs[t.id!]?.description,
388+ lastActive: t.lastAccessed ?? 0,
389+ });
390+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map(toMember);
391+ const batch = makeBatches(ordered)[0];
392+ const maxGroups = maxGroupsFor(batch.length);
393+ const payload: OrganizePayload = {
394+ items: toItems(batch, settings),
395+ existingGroups,
396+ allowNew: batch.length >= 2,
397+ maxGroups,
398+ minGroupSize: settings.organizeMinGroupSize,
399+ };
400+ let reply = await rt.host<OrganizeResult>("organize", payload);
401+ if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
402+ // Rare with the budgeted batch; try once with half, the rest follows on the next run.
403+ const half = batch.slice(0, Math.ceil(batch.length / 2));
404+ payload.items = toItems(half, settings);
405+ payload.maxGroups = maxGroupsFor(half.length);
406+ payload.allowNew = half.length >= 2;
407+ batch.length = half.length;
408+ reply = await rt.host<OrganizeResult>("organize", payload);
409+ }
410+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
411+ if (!reply.ok) {
412+ rt.log("auto-organize", reply.error.code);
413+ this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
414+ return;
415+ }
416+ const batchIds = batch.map((m) => m.id);
417+ const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, payload.maxGroups, (ids) =>
418+ colorFor(batch.find((m) => m.id === ids[0])?.url ?? ""),
419+ );
420+ await applyPlan(rt, plan, windowId, batch, {}, []);
421+ const live = rt.state();
422+ for (const m of batch) {
423+ const rec = live.tabs[m.id];
424+ if (rec) rec.organizedKey = organizedKeyOf(m);
425+ }
426+ rt.commit();
427+ const remaining = fresh.filter((t) => !batchIds.includes(t.id!));
428+ if (remaining.length) this.touch(windowId, AUTO_CONTINUE_MS);
429+ }
430+}
431+
297432export { COLORS };
src/background/settings.ts+6−1
@@ -5,6 +5,8 @@ export type ModelChoice = "system" | "pcc";
55export interface Settings {
66 openerGrouping: boolean;
77 dissolveSingletons: boolean;
8+ autoOrganize: boolean;
9+ autoOrganizeDelayMs: number;
810 naming: boolean;
911 nameUserGroups: boolean;
1012 emoji: boolean;
@@ -28,6 +30,8 @@ export interface Settings {
2830export const DEFAULT_SETTINGS: Settings = {
2931 openerGrouping: true,
3032 dissolveSingletons: true,
33+ autoOrganize: true,
34+ autoOrganizeDelayMs: 8000,
3135 naming: true,
3236 nameUserGroups: false,
3337 emoji: true,
@@ -37,7 +41,7 @@ export const DEFAULT_SETTINGS: Settings = {
3741 model: "system",
3842 timeoutMs: 45000,
3943 organizeMinGroupSize: 2,
40− tidyMode: "ask",
44+ tidyMode: "auto",
4145 parkAfterHours: 24,
4246 archiveAfterHours: 48,
4347 tidyThreshold: 10,
@@ -50,6 +54,7 @@ export const DEFAULT_SETTINGS: Settings = {
5054
5155const RANGES: Partial<Record<keyof Settings, [number, number]>> = {
5256 namingDebounceMs: [1000, 30000],
57+ autoOrganizeDelayMs: [3000, 120000],
5358 timeoutMs: [5000, 120000],
5459 organizeMinGroupSize: [2, 5],
5560 parkAfterHours: [1, 720],
src/background/state.ts+6
@@ -18,6 +18,8 @@ export interface TabRecord {
1818 createdAt: number;
1919 lastActivatedAt: number; // epoch ms
2020 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
22+ organizedKey?: string; // page + title when auto-organize last considered this tab
2123}
2224
2325export interface GroupRecord {
@@ -113,6 +115,8 @@ export interface State {
113115 ownWrites: Record<number, { title?: string; color?: string; at: number }>;
114116 /** Groups the worker is about to create: onCreated within 2 s in that window is ours. */
115117 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
118+ /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
119+ ownUngroups: Record<number, number>;
116120 lastSweep?: { at: number; moves: SweepMove[] };
117121 lastOrganize?: { at: number; previous: Record<number, number> };
118122 tidyCandidates?: number;
@@ -126,6 +130,7 @@ export const emptyState = (): State => ({
126130 host: { consecutiveFailures: 0 },
127131 ownWrites: {},
128132 pendingCreates: [],
133+ ownUngroups: {},
129134});
130135
131136/** Accept whatever was stored and bring it to the current shape. */
@@ -145,6 +150,7 @@ export function migrate(raw: unknown): State {
145150 host: { consecutiveFailures: 0, ...(r.host ?? {}) },
146151 ownWrites: r.ownWrites ?? {},
147152 pendingCreates: r.pendingCreates ?? [],
153+ ownUngroups: r.ownUngroups ?? {},
148154 };
149155}
150156
src/background/tidy.ts+2−1
@@ -120,7 +120,8 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
120120 let tabs = await liveTabs();
121121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122122 report.candidates = candidates.length;
123− const park = manual || (settings.tidyMode === "auto" && candidates.length >= settings.tidyThreshold);
123+ // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
124+ const park = manual || settings.tidyMode === "auto";
124125 if (park && candidates.length) {
125126 report.parked = await parkTabs(rt, candidates);
126127 tabs = await liveTabs();
src/options/options.ts+6−4
@@ -11,8 +11,10 @@ type Field =
1111
1212const SECTIONS: [string, Field[]][] = [
1313 ["Grouping", [
14+ { key: "autoOrganize", label: "Organize loose tabs into groups automatically", help: "Tabs you take out of a group stay out until they go to another page.", kind: "bool" },
15+ { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, step: 1, unit: "s", scale: 1000 },
1416 { key: "openerGrouping", label: "Group a tab with the tab it was opened from", kind: "bool" },
15− { key: "dissolveSingletons", label: "Ungroup opener groups that drop to one tab", kind: "bool" },
17+ { key: "dissolveSingletons", label: "Ungroup Diagonal's groups that drop to one tab", kind: "bool" },
1618 ]],
1719 ["Naming", [
1820 { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
@@ -23,13 +25,13 @@ const SECTIONS: [string, Field[]][] = [
2325 { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
2426 { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
2527 { key: "timeoutMs", label: "Timeout per model call", kind: "number", min: 5, max: 120, step: 1, unit: "s", scale: 1000 },
26− { key: "organizeMinGroupSize", label: "Smallest group Organize creates", kind: "number", min: 2, max: 5, unit: "tabs" },
28+ { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs" },
2729 ]],
2830 ["Tidy", [
29− { key: "tidyMode", label: "Tidy sweep", kind: "select", options: [["ask", "Ask before parking"], ["auto", "Park automatically"], ["off", "Off"]] },
31+ { key: "tidyMode", label: "Tidy sweep", kind: "select", options: [["auto", "Park automatically"], ["ask", "Ask before parking"], ["off", "Off"]] },
3032 { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
3133 { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 = never archive.", kind: "number", min: 0, max: 2160, unit: "h" },
32− { key: "tidyThreshold", label: "Only offer a sweep with at least", kind: "number", min: 1, max: 100, unit: "tabs" },
34+ { key: "tidyThreshold", label: "Ask mode: offer a sweep at", kind: "number", min: 1, max: 100, unit: "tabs" },
3335 { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
3436 { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
3537 { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Write /…/ for a regular expression.", kind: "list" },
src/popup/popup.html+1−1
@@ -21,7 +21,7 @@
2121 <div id="notice" class="notice" hidden></div>
2222
2323 <div class="actions">
24− <button class="primary" id="organize">Organize this window</button>
24+ <button id="organize">Organize now</button>
2525 <button id="tidy">Tidy now</button>
2626 <button id="park" hidden></button>
2727 </div>
src/popup/popup.ts+1−1
@@ -136,7 +136,7 @@ async function main(): Promise<void> {
136136 b.textContent = "Organizing…";
137137 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
138138 b.disabled = false;
139− b.textContent = "Organize this window";
139+ b.textContent = "Organize now";
140140 if (r) showResult(`${r.grouped} tabs grouped into ${r.groups} new group${r.groups === 1 ? "" : "s"}, ${r.left} left${r.error ? ` (stopped: ${r.error})` : ""}.`);
141141 };
142142 const tidy = async () => {
test/auto.test.tsnew file+146
@@ -0,0 +1,146 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+import type { HostReply } from "../src/background/host";
3+import { AutoOrganizer, organizedKeyOf, type OrganizePayload } from "../src/background/organize";
4+import type { Runtime } from "../src/background/runtime";
5+import { newGroupRecord, emptyState, type State } from "../src/background/state";
6+import { settings as makeSettings } from "./helpers";
7+
8+/** AutoOrganizer against a small in-memory tab strip standing in for `chrome.tabs`. */
9+
10+type LiveTab = { id: number; windowId: number; index: number; groupId: number; url: string; title: string; status: string; pinned: boolean };
11+
12+let live: LiveTab[];
13+let nextGroup: number;
14+let state: State;
15+let calls: OrganizePayload[];
16+let replies: HostReply<unknown>[];
17+let fallbacks: number[];
18+
19+const 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+});
22+
23+function remember(t: LiveTab, extra: object = {}): void {
24+ state.tabs[t.id] = { id: t.id, windowId: t.windowId, groupId: t.groupId, index: t.index, url: t.url, title: t.title, pinned: t.pinned, status: t.status, createdAt: 0, lastActivatedAt: 0, ...extra };
25+}
26+
27+function setup(tabs: LiveTab[]): AutoOrganizer {
28+ live = tabs;
29+ for (const t of tabs) remember(t);
30+ const rt: Runtime = {
31+ state: () => state,
32+ settings: () => makeSettings(),
33+ commit: () => undefined,
34+ now: () => 1_000_000,
35+ host: async <T,>(_op: string, payload: object) => {
36+ calls.push(JSON.parse(JSON.stringify(payload)));
37+ return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
38+ },
39+ naming: { touch: () => undefined } as unknown as Runtime["naming"],
40+ refreshBadge: () => undefined,
41+ log: () => undefined,
42+ };
43+ return new AutoOrganizer(rt, { scheduleFallback: (when) => fallbacks.push(when) });
44+}
45+
46+beforeEach(() => {
47+ state = emptyState();
48+ calls = [];
49+ replies = [];
50+ fallbacks = [];
51+ nextGroup = 100;
52+ (globalThis as any).chrome = {
53+ tabs: {
54+ query: async (q: { windowId?: number; groupId?: number }) =>
55+ live.filter((t) => (q.windowId === undefined || t.windowId === q.windowId) && (q.groupId === undefined || t.groupId === q.groupId)).map((t) => ({ ...t })),
56+ group: async ({ tabIds, groupId }: { tabIds: number[]; groupId?: number }) => {
57+ const id = groupId ?? nextGroup++;
58+ for (const t of live) if (tabIds.includes(t.id)) t.groupId = id;
59+ return id;
60+ },
61+ ungroup: async () => undefined,
62+ },
63+ tabGroups: { update: async () => undefined },
64+ windows: { getAll: async () => [{ id: 1, incognito: false }] },
65+ };
66+});
67+
68+afterEach(() => {
69+ delete (globalThis as any).chrome;
70+});
71+
72+describe("auto-organize", () => {
73+ it("groups loose tabs the model clusters, and remembers it looked at them", async () => {
74+ const auto = setup([liveTab(1), liveTab(2), liveTab(3)]);
75+ replies.push({ ok: true, result: { groups: [{ title: "Rust async", emoji: "🦀", color: "orange", members: [0, 1] }], leftovers: [2] } });
76+ await auto.run(1);
77+ expect(calls).toHaveLength(1);
78+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 1", "Tab 2", "Tab 3"]);
79+ expect(live.map((t) => t.groupId)).toEqual([100, 100, -1]);
80+ expect(state.groups[100]).toMatchObject({ origin: "organize", managed: true, title: "Rust async" });
81+ expect(state.tabs[3].organizedKey).toBe(organizedKeyOf(live[2]));
82+
83+ await auto.run(1);
84+ expect(calls).toHaveLength(1); // nothing changed: no second call
85+ });
86+
87+ it("a new tab is sent first, with the leftovers it may pair with and Diagonal's groups it may join", async () => {
88+ const auto = setup([liveTab(1, { groupId: 50 }), liveTab(2), liveTab(3)]);
89+ state.groups[50] = newGroupRecord(50, 1, "opener", "blue", { managed: true, stripTitle: "🦀 Rust", title: "Rust" });
90+ state.tabs[2].organizedKey = organizedKeyOf(live[1]);
91+ replies.push({ ok: true, result: { groups: [{ title: "Rust", emoji: "🦀", color: "blue", existing: 0, members: [0] }], leftovers: [1] } });
92+ await auto.run(1);
93+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
94+ expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
95+ expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
96+ });
97+
98+ it("leaves alone tabs you took out of a group, pinned tabs, new tab pages and tabs still loading", async () => {
99+ const auto = setup([
100+ liveTab(1),
101+ liveTab(2, { pinned: true }),
102+ liveTab(3, { url: "brave://newtab/" }),
103+ liveTab(4, { status: "loading" }),
104+ liveTab(5),
105+ liveTab(6),
106+ ]);
107+ state.tabs[1].keepLoose = "https://site1.com/page";
108+ await auto.run(1);
109+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
110+ });
111+
112+ it("one loose tab with nothing to join waits for company", async () => {
113+ const auto = setup([liveTab(1)]);
114+ await auto.run(1);
115+ expect(calls).toHaveLength(0);
116+ });
117+
118+ it("a failed call leaves the tabs fresh and schedules a retry", async () => {
119+ const auto = setup([liveTab(1), liveTab(2)]);
120+ replies.push({ ok: false, error: { code: "TIMEOUT", message: "slow" } });
121+ await auto.run(1);
122+ expect(state.tabs[1].organizedKey).toBeUndefined();
123+ expect(fallbacks.length).toBe(1);
124+ });
125+
126+ it("does not call the host while it is paused", async () => {
127+ const auto = setup([liveTab(1), liveTab(2)]);
128+ state.host.pausedUntil = 2_000_000;
129+ await auto.run(1);
130+ expect(calls).toHaveLength(0);
131+ });
132+
133+ it("debounces: several touches in a row make one run", async () => {
134+ vi.useFakeTimers();
135+ try {
136+ const auto = setup([liveTab(1), liveTab(2)]);
137+ auto.touch(1);
138+ auto.touch(1);
139+ auto.touch(1);
140+ await vi.advanceTimersByTimeAsync(makeSettings().autoOrganizeDelayMs + 10);
141+ expect(calls).toHaveLength(1);
142+ } finally {
143+ vi.useRealTimers();
144+ }
145+ });
146+});
test/e2e/smoke.mjs+34−5
@@ -1,6 +1,6 @@
11// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/diagonal-host.py)
22// wired to a keyword-driven fake fm, then drives opener grouping, naming, a user rename, dissolve,
3−// Organize and the popup. Linux only (native host manifests live under the profile directory).
3+// Organize, auto-organize and the popup. Linux only (native host manifests live under the profile directory).
44// node test/e2e/smoke.mjs (after npm run build)
55import { chromium } from "playwright-core";
66import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
@@ -90,8 +90,9 @@ try {
9090 }
9191 };
9292
93− // Settings: short debounce so the test is quick.
94− await sw.evaluate(async () => chrome.storage.local.set({ settings: { namingDebounceMs: 1000 } }));
93+ // Settings: short debounce so the test is quick; auto-organize stays off until step 6 so the
94+ // earlier steps see only the rule they test.
95+ await sw.evaluate(async () => chrome.storage.local.set({ settings: { namingDebounceMs: 1000, autoOrganize: false } }));
9596
9697 // Wait for boot's ping.
9798 const pinged = await until(() => sw.evaluate(async () => (await chrome.storage.local.get("state")).state?.host?.lastPing?.fmAvailable === true));
@@ -157,7 +158,35 @@ try {
157158 const afterUndo = await sw.evaluate(async (w) => (await chrome.tabGroups.query({ windowId: w })).length, win);
158159 check("Undo organize restores the previous layout", undone?.result === 4 && afterUndo === 0, JSON.stringify({ undone, afterUndo }));
159160
160− // 6. Popup renders the healthy state.
161+ // 6. Auto-organize: the same tabs in a fresh window get grouped with no button pressed.
162+ await sw.evaluate(async () => chrome.storage.local.set({ settings: { namingDebounceMs: 1000, autoOrganize: true, autoOrganizeDelayMs: 3000 } }));
163+ const win2 = await sw.evaluate(async (b) => {
164+ const w = await chrome.windows.create({ url: `${b}/hotel` });
165+ for (const p of ["travel", "pasta", "cook", "misc"]) await chrome.tabs.create({ windowId: w.id, url: `${b}/${p}` });
166+ return w.id;
167+ }, base);
168+ const titlesIn = (w) => sw.evaluate(async (w) => (await chrome.tabGroups.query({ windowId: w })).map((g) => g.title).sort().join("|"), w);
169+ const autoTitles = await until(async () => {
170+ const t = await titlesIn(win2);
171+ return t.split("|").length === 2 ? t : undefined;
172+ }, 25000);
173+ check("loose tabs are organized on their own once the window settles", autoTitles === "✈️ Lisbon trip planning|🍳 Weeknight pasta recipes", autoTitles ?? (await titlesIn(win2)));
174+
175+ const tabIn = (w, path) => sw.evaluate(async ([w, path]) => (await chrome.tabs.query({ windowId: w })).find((t) => t.url.endsWith(path)), [w, path]);
176+ const hotel = await tabIn(win2, "/hotel");
177+ await sw.evaluate(async (id) => chrome.tabs.ungroup(id), hotel.id);
178+ await sleep(5000);
179+ check("a tab you take out of a group stays out", (await tabIn(win2, "/hotel")).groupId === -1);
180+
181+ const cook = await tabIn(win2, "/cook");
182+ const pastaGroup = cook.groupId;
183+ await sw.evaluate(async (id) => chrome.tabs.remove(id), cook.id);
184+ const gone = await until(async () => !(await sw.evaluate(async (w) => (await chrome.tabGroups.query({ windowId: w })).map((g) => g.id), win2)).includes(pastaGroup), 6000);
185+ await sleep(4500);
186+ 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 }));
188+
189+ // 7. Popup renders the healthy state.
161190 await popup.setViewportSize({ width: 360, height: 560 });
162191 await popup.reload();
163192 await sleep(800);
@@ -174,7 +203,7 @@ try {
174203 await options.setViewportSize({ width: 900, height: 1400 });
175204 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
176205
177− // 7. Host missing → HOST_NOT_FOUND surfaced.
206+ // 8. Host missing → HOST_NOT_FOUND surfaced.
178207 writeFileSync(join(profile, "NativeMessagingHosts/io.diagonal.host.json"), "{}");
179208 writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.diagonal.host.json"), "{}");
180209 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
test/engine.test.ts+74−6
@@ -13,10 +13,10 @@ describe("opener rule", () => {
1313 ]);
1414 });
1515
16− it("Cmd-click from a pinned tab spawns a plain tab", () => {
16+ it("Cmd-click from a pinned tab spawns a plain tab, left to auto-organize", () => {
1717 const opener = tab(1, { pinned: true });
1818 const { actions } = step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, ctx());
19− expect(actions).toEqual([]);
19+ expect(actions).toEqual([{ type: "loose", windowId: 1 }]);
2020 });
2121
2222 it("skips pinned, blank and internal children", () => {
@@ -44,7 +44,8 @@ describe("opener rule", () => {
4444
4545 it("does nothing when opener grouping is off", () => {
4646 const opener = tab(1);
47− expect(step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, ctx(0, { openerGrouping: false })).actions).toEqual([]);
47+ const off = ctx(0, { openerGrouping: false, autoOrganize: false });
48+ expect(step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, off).actions).toEqual([]);
4849 });
4950
5051 it("ignores incognito tabs entirely", () => {
@@ -72,11 +73,22 @@ describe("dissolve rule", () => {
7273 expect(step(s, { type: "dissolveCheck", groupId: 10, memberIds: [1, 3] }, ctx()).actions).toEqual([]);
7374 });
7475
75− it("organize groups shrink to one member without dissolving", () => {
76+ it("organize groups dissolve at one member too", () => {
7677 const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
7778 const r1 = step(s, { type: "tabRemoved", tabId: 2 }, ctx());
78− expect(r1.actions.some((a) => a.type === "scheduleDissolve")).toBe(false);
79− expect(step(r1.state, { type: "dissolveCheck", groupId: 10, memberIds: [1] }, ctx()).actions).toEqual([]);
79+ expect(r1.actions).toContainEqual({ type: "scheduleDissolve", groupId: 10, delayMs: DISSOLVE_DELAY_MS });
80+ expect(step(r1.state, { type: "dissolveCheck", groupId: 10, memberIds: [1] }, ctx()).actions).toEqual([{ type: "ungroup", tabIds: [1] }]);
81+ });
82+
83+ it("a group you titled yourself is not dissolved", () => {
84+ const s = stateWith([tab(1, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
85+ s.groups[10].userNamed = true;
86+ expect(step(s, { type: "dissolveCheck", groupId: 10, memberIds: [1] }, ctx()).actions).toEqual([]);
87+ });
88+
89+ it("the Parked group is not dissolved", () => {
90+ const s = stateWith([tab(1, { groupId: 10 })], [{ id: 10, origin: "tidy" }]);
91+ expect(step(s, { type: "dissolveCheck", groupId: 10, memberIds: [1] }, ctx()).actions).toEqual([]);
8092 });
8193
8294 it("user groups are never dissolved", () => {
@@ -184,3 +196,59 @@ describe("dirty triggers", () => {
184196 expect(JSON.stringify(s)).toBe(before);
185197 });
186198});
199+
200+describe("auto-organize triggers", () => {
201+ it("a new tab that navigates somewhere real asks for a look at its window", () => {
202+ const s = stateWith([tab(1, { url: "brave://newtab/" })]);
203+ const { actions } = step(s, { type: "tabUpdated", tab: tab(1, { url: "https://tokio.rs/" }) }, ctx());
204+ expect(actions).toEqual([{ type: "loose", windowId: 1 }]);
205+ });
206+
207+ it("new tab pages, pinned tabs and grouped tabs do not", () => {
208+ expect(step(stateWith([]), { type: "tabCreated", tab: tab(1, { url: "brave://newtab/" }) }, ctx()).actions).toEqual([]);
209+ expect(step(stateWith([]), { type: "tabCreated", tab: tab(1, { pinned: true }) }, ctx()).actions).toEqual([]);
210+ const g = stateWith([tab(1, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
211+ expect(step(g, { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "new" }) }, ctx()).actions).not.toContainEqual({ type: "loose", windowId: 1 });
212+ });
213+
214+ it("does nothing when auto-organize is off", () => {
215+ const s = stateWith([tab(1, { url: "brave://newtab/" })]);
216+ expect(step(s, { type: "tabUpdated", tab: tab(1, { url: "https://tokio.rs/" }) }, ctx(0, { autoOrganize: false })).actions).toEqual([]);
217+ });
218+
219+ it("a tab you take out of a group stays loose until it goes to another page", () => {
220+ const s = stateWith([tab(1, { groupId: 10, url: "https://a.com/x" }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
221+ const r1 = step(s, { type: "tabUpdated", tab: tab(1, { url: "https://a.com/x" }) }, ctx());
222+ expect(r1.state.tabs[1].keepLoose).toBe("https://a.com/x");
223+ expect(r1.actions).not.toContainEqual({ type: "loose", windowId: 1 });
224+ const r2 = step(r1.state, { type: "tabUpdated", tab: tab(1, { url: "https://a.com/x", title: "retitled" }) }, ctx());
225+ expect(r2.actions).toEqual([]);
226+ const r3 = step(r2.state, { type: "tabUpdated", tab: tab(1, { url: "https://b.com/y" }) }, ctx());
227+ expect(r3.state.tabs[1].keepLoose).toBeUndefined();
228+ expect(r3.actions).toEqual([{ type: "loose", windowId: 1 }]);
229+ });
230+
231+ it("a tab Diagonal ungrouped itself (dissolve) is fair game again", () => {
232+ const s = stateWith([tab(1, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
233+ s.ownUngroups[1] = 999_000;
234+ const { state, actions } = step(s, { type: "tabUpdated", tab: tab(1) }, ctx());
235+ expect(state.tabs[1].keepLoose).toBeUndefined();
236+ expect(state.ownUngroups[1]).toBeUndefined();
237+ expect(actions).toContainEqual({ type: "loose", windowId: 1 });
238+ });
239+
240+ it("opening a parked tab takes it out of Parked", () => {
241+ const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
242+ s.tabs[1].parkedFrom = "Rust async";
243+ const { state, actions } = step(s, { type: "tabActivated", tabId: 1 }, ctx());
244+ expect(actions).toEqual([{ type: "unpark", tabId: 1, groupId: 20 }]);
245+ expect(state.tabs[1].parkedFrom).toBeUndefined();
246+ });
247+
248+ it("activating a tab anywhere else only records the time", () => {
249+ const s = stateWith([tab(1, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
250+ const { state, actions } = step(s, { type: "tabActivated", tabId: 1 }, ctx(5_000_000));
251+ expect(actions).toEqual([]);
252+ expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
253+ });
254+});
test/url.test.ts+1−1
@@ -80,7 +80,7 @@ describe("settings", () => {
8080 it("fills defaults and clamps", () => {
8181 const s = withDefaults({ namingDebounceMs: 10, tidyMode: "weird", emoji: false, tidyExclusions: ["a", "", 3] });
8282 expect(s.namingDebounceMs).toBe(1000);
83− expect(s.tidyMode).toBe("ask");
83+ expect(s.tidyMode).toBe("auto");
8484 expect(s.emoji).toBe(false);
8585 expect(s.tidyExclusions).toEqual(["a"]);
8686 expect(withDefaults(undefined).parkAfterHours).toBe(24);