on master help sign in
Organize tabs automatically, with no shortcuts or buttons needed
98ea35dClaude
README.md+10−16
@@ -2,12 +2,15 @@
2
2
3Dia-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.
3Dia-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.
4
4
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.
6
6
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.
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.
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. **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. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
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.
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.
11
14
12The model sees a tab's title, address and `<meta name="description">`, and nothing else.
15The model sees a tab's title, address and `<meta name="description">`, and nothing else.
13
16
@@ -57,16 +60,6 @@ programs.diagonal.enable = true;
57
60
58Open 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.
61Open 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.
59
62
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−
70## Develop
63## Develop
71
64
72```sh
65```sh
@@ -94,7 +87,8 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
94
87
95## Where this differs from the spec, and why
88## Where this differs from the spec, and why
96
89
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.
98- **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.
92- **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.
99- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
93- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
100- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
94- **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 @@
49 "48": "icons/48.png",
49 "48": "icons/48.png",
50 "128": "icons/128.png"
50 "128": "icons/128.png"
51 },
51 },
52− "options_page": "options.html",
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− }
72}
53}
src/background/engine.ts+42−7
@@ -48,7 +48,9 @@ export type Action =
48 | { type: "addToGroup"; tabIds: number[]; groupId: number }
48 | { type: "addToGroup"; tabIds: number[]; groupId: number }
49 | { type: "ungroup"; tabIds: number[] }
49 | { type: "ungroup"; tabIds: number[] }
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 }
53+ | { type: "unpark"; tabId: number; groupId: number };
52
54
53export interface Ctx {
55export interface Ctx {
54 now: number;
56 now: number;
@@ -84,7 +86,9 @@ const handlers: Handlers = {
84 if (tab.incognito) return;
86 if (tab.incognito) return;
85 const rec = upsertTab(s, tab, ctx.now);
87 const rec = upsertTab(s, tab, ctx.now);
86 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
88 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
89+ const before = out.length;
87 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
90 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
91+ if (out.length === before) loose(rec, ctx, out);
88 },
92 },
89
93
90 tabUpdated(s, { tab }, ctx, out) {
94 tabUpdated(s, { tab }, ctx, out) {
@@ -93,16 +97,31 @@ const handlers: Handlers = {
93 const rec = upsertTab(s, tab, ctx.now);
97 const rec = upsertTab(s, tab, ctx.now);
94 if (!before) {
98 if (!before) {
95 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
99 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
100+ else loose(rec, ctx, out);
96 return;
101 return;
97 }
102 }
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
98 if (before.groupId !== rec.groupId) {
106 if (before.groupId !== rec.groupId) {
99 left(s, before.groupId, ctx, out);
107 left(s, before.groupId, ctx, out);
100 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
108 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);
101 return;
123 return;
102 }
124 }
103− if (rec.groupId === -1) return;
104− const titleChanged = before.title !== rec.title;
105− const pathChanged = pathKey(before.url) !== pathKey(rec.url);
106 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
107 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
108 // A member finished loading: the naming loop may have been waiting for it.
127 // A member finished loading: the naming loop may have been waiting for it.
@@ -117,9 +136,15 @@ const handlers: Handlers = {
117 left(s, rec.groupId, ctx, out);
136 left(s, rec.groupId, ctx, out);
118 },
137 },
119
138
120− tabActivated(s, { tabId }, ctx) {
139+ tabActivated(s, { tabId }, ctx, out) {
121 const rec = s.tabs[tabId];
140 const rec = s.tabs[tabId];
141+ if (!rec) return;
122− if (rec) rec.lastActivatedAt = ctx.now;
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+ }
123 },
148 },
124
149
125 groupCreated(s, { group }, ctx, out) {
150 groupCreated(s, { group }, ctx, out) {
@@ -207,8 +232,15 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
207 });
232 });
208}
233}
209
234
235+/** Groups Diagonal made (opener or topic) go away when one tab is left; the Parked group and yours stay. */
210export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
236export 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+}
212
244
213/** A tab left `groupId`: the group changed, and an opener group may now be a singleton. */
245/** A tab left `groupId`: the group changed, and an opener group may now be a singleton. */
214function left(s: State, groupId: number, ctx: Ctx, out: Action[]): void {
246function left(s: State, groupId: number, ctx: Ctx, out: Action[]): void {
@@ -271,6 +303,8 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
271 else if (before?.openerTabId !== undefined) rec.openerTabId = before.openerTabId;
303 else if (before?.openerTabId !== undefined) rec.openerTabId = before.openerTabId;
272 // A description belongs to a page: keep it only while the tab stays on that page.
304 // A description belongs to a page: keep it only while the tab stays on that page.
273 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
305 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;
274 s.tabs[tab.id] = rec;
308 s.tabs[tab.id] = rec;
275 return rec;
309 return rec;
276}
310}
@@ -292,4 +326,5 @@ export function pathKey(url: string | undefined): string {
292function prune(s: State, now: number): void {
326function prune(s: State, now: number): void {
293 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
294 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
328 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];
295}
330}
src/background/index.ts+41−28
@@ -2,7 +2,7 @@ import { stripTitle } from "../shared/label";
2import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
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";
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";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
7import { withDefaults, type Settings } from "./settings";
7import { withDefaults, type Settings } from "./settings";
8import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
8import { 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
13const ALARM_NAMING = "naming-fallback";
13const ALARM_NAMING = "naming-fallback";
14const ALARM_TIDY = "tidy-sweep";
14const ALARM_TIDY = "tidy-sweep";
15const ALARM_HOST = "host-retry";
15const ALARM_HOST = "host-retry";
16+const ALARM_AUTO = "auto-organize";
16const HOST_RETRY_MS = 600_000;
17const HOST_RETRY_MS = 600_000;
17
18
18// ----- state cache ------------------------------------------------------------------------------
19// ----- state cache ------------------------------------------------------------------------------
@@ -65,11 +66,21 @@ function logError(where: string, e: unknown): void {
65
66
66// ----- host ------------------------------------------------------------------------------------
67// ----- host ------------------------------------------------------------------------------------
67
68
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+
68async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}): Promise<HostReply<T>> {
78async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}): Promise<HostReply<T>> {
69 const opts: HostOpts = { model: settings.model, timeoutMs: settings.timeoutMs, emoji: settings.emoji, debug: settings.debugLog, ...extra };
79 const opts: HostOpts = { model: settings.model, timeoutMs: settings.timeoutMs, emoji: settings.emoji, debug: settings.debugLog, ...extra };
70 busy(true);
80 busy(true);
71 try {
81 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);
73 const h = state.host;
84 const h = state.host;
74 if (reply.ok) {
85 if (reply.ok) {
75 h.lastOkAt = Date.now();
86 h.lastOkAt = Date.now();
@@ -122,6 +133,7 @@ async function ping(): Promise<HostReply<PingResult>> {
122 h.lastError = undefined;
133 h.lastError = undefined;
123 chrome.alarms.clear(ALARM_HOST);
134 chrome.alarms.clear(ALARM_HOST);
124 void naming.sweepDirty();
135 void naming.sweepDirty();
136+ void autoOrganizer.sweep();
125 }
137 }
126 }
138 }
127 commit();
139 commit();
@@ -213,6 +225,15 @@ const rt: Runtime = {
213 log,
225 log,
214};
226};
215
227
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+
216// ----- engine glue -----------------------------------------------------------------------------
237// ----- engine glue -----------------------------------------------------------------------------
217
238
218let chain: Promise<unknown> = Promise.resolve();
239let chain: Promise<unknown> = Promise.resolve();
@@ -274,8 +295,20 @@ async function run(a: Action): Promise<void> {
274 await addToGroup(rt, a.tabIds, a.groupId);
295 await addToGroup(rt, a.tabIds, a.groupId);
275 break;
296 break;
276 case "ungroup":
297 case "ungroup":
298+ for (const id of a.tabIds) state.ownUngroups[id] = Date.now();
277 await ungroup(rt, a.tabIds);
299 await ungroup(rt, a.tabIds);
278 break;
300 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;
279 case "scheduleDissolve":
312 case "scheduleDissolve":
280 // Re-checked after the delay so Cmd+Shift+T right after a close does not flicker.
313 // Re-checked after the delay so Cmd+Shift+T right after a close does not flicker.
281 setTimeout(() => {
314 setTimeout(() => {
@@ -324,6 +357,8 @@ async function reconcile(): Promise<void> {
324 openerTabId: t.openerTabId,
357 openerTabId: t.openerTabId,
325 description: prev?.description,
358 description: prev?.description,
326 parkedFrom: prev?.parkedFrom,
359 parkedFrom: prev?.parkedFrom,
360+ keepLoose: prev?.keepLoose,
361+ organizedKey: prev?.organizedKey,
327 createdAt: prev?.createdAt ?? now,
362 createdAt: prev?.createdAt ?? now,
328 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
363 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
329 };
364 };
@@ -351,6 +386,7 @@ async function reconcile(): Promise<void> {
351 state.groups = nextGroups;
386 state.groups = nextGroups;
352 state.pendingCreates = [];
387 state.pendingCreates = [];
353 state.ownWrites = {};
388 state.ownWrites = {};
389+ state.ownUngroups = {};
354 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
390 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
355 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
391 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
356 commit();
392 commit();
@@ -373,6 +409,7 @@ async function boot(reason: "startup" | "installed"): Promise<void> {
373 await ping();
409 await ping();
374 await naming.sweepDirty();
410 await naming.sweepDirty();
375 if (reason === "startup") void runSweep(rt).catch((e) => logError("tidy", e));
411 if (reason === "startup") void runSweep(rt).catch((e) => logError("tidy", e));
412+ await autoOrganizer.sweep();
376 refreshBadge();
413 refreshBadge();
377}
414}
378
415
@@ -425,6 +462,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
425 void (async () => {
462 void (async () => {
426 await ready;
463 await ready;
427 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
464 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
465+ else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
428 else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
466 else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
429 else if (alarm.name === ALARM_HOST) {
467 else if (alarm.name === ALARM_HOST) {
430 const r = await ping();
468 const r = await ping();
@@ -441,6 +479,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
441 settings = withDefaults(changes.settings.newValue);
479 settings = withDefaults(changes.settings.newValue);
442 if (before.emoji !== settings.emoji) await rewriteTitles();
480 if (before.emoji !== settings.emoji) await rewriteTitles();
443 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
481 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
482+ if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
444 refreshBadge();
483 refreshBadge();
445 })();
484 })();
446});
485});
@@ -453,13 +492,6 @@ async function rewriteTitles(): Promise<void> {
453 }
492 }
454}
493}
455
494
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−
463async function nameGroupNow(groupId: number): Promise<void> {
495async function nameGroupNow(groupId: number): Promise<void> {
464 const g = state.groups[groupId];
496 const g = state.groups[groupId];
465 if (!g) return;
497 if (!g) return;
@@ -470,25 +502,6 @@ async function nameGroupNow(groupId: number): Promise<void> {
470 await naming.nameNow(groupId);
502 await naming.nameNow(groupId);
471}
503}
472
504
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−
492// ----- messages (content script, popup, options) -----------------------------------------------
505// ----- messages (content script, popup, options) -----------------------------------------------
493
506
494type Msg = { kind?: string; cmd?: string; [k: string]: any };
507type Msg = { kind?: string; cmd?: string; [k: string]: any };
src/background/organize.ts+136−1
@@ -1,6 +1,7 @@
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
2import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4+import { pathKey } from "./engine";
4import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { markDirty, type State } from "./state";
7import { markDirty, type State } from "./state";
@@ -227,7 +228,7 @@ export async function organizeWindow(rt: Runtime, windowId: number): Promise<Org
227 return report;
228 return report;
228}
229}
229
230
230−async function applyPlan(
231+export async function applyPlan(
231 rt: Runtime,
232 rt: Runtime,
232 plan: Plan,
233 plan: Plan,
233 windowId: number,
234 windowId: number,
@@ -294,4 +295,138 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
294 return restored;
295 return restored;
295}
296}
296
297
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+
297export { COLORS };
432export { COLORS };
src/background/settings.ts+6−1
@@ -5,6 +5,8 @@ export type ModelChoice = "system" | "pcc";
5export interface Settings {
5export interface Settings {
6 openerGrouping: boolean;
6 openerGrouping: boolean;
7 dissolveSingletons: boolean;
7 dissolveSingletons: boolean;
8+ autoOrganize: boolean;
9+ autoOrganizeDelayMs: number;
8 naming: boolean;
10 naming: boolean;
9 nameUserGroups: boolean;
11 nameUserGroups: boolean;
10 emoji: boolean;
12 emoji: boolean;
@@ -28,6 +30,8 @@ export interface Settings {
28export const DEFAULT_SETTINGS: Settings = {
30export const DEFAULT_SETTINGS: Settings = {
29 openerGrouping: true,
31 openerGrouping: true,
30 dissolveSingletons: true,
32 dissolveSingletons: true,
33+ autoOrganize: true,
34+ autoOrganizeDelayMs: 8000,
31 naming: true,
35 naming: true,
32 nameUserGroups: false,
36 nameUserGroups: false,
33 emoji: true,
37 emoji: true,
@@ -37,7 +41,7 @@ export const DEFAULT_SETTINGS: Settings = {
37 model: "system",
41 model: "system",
38 timeoutMs: 45000,
42 timeoutMs: 45000,
39 organizeMinGroupSize: 2,
43 organizeMinGroupSize: 2,
40− tidyMode: "ask",
44+ tidyMode: "auto",
41 parkAfterHours: 24,
45 parkAfterHours: 24,
42 archiveAfterHours: 48,
46 archiveAfterHours: 48,
43 tidyThreshold: 10,
47 tidyThreshold: 10,
@@ -50,6 +54,7 @@ export const DEFAULT_SETTINGS: Settings = {
50
54
51const RANGES: Partial<Record<keyof Settings, [number, number]>> = {
55const RANGES: Partial<Record<keyof Settings, [number, number]>> = {
52 namingDebounceMs: [1000, 30000],
56 namingDebounceMs: [1000, 30000],
57+ autoOrganizeDelayMs: [3000, 120000],
53 timeoutMs: [5000, 120000],
58 timeoutMs: [5000, 120000],
54 organizeMinGroupSize: [2, 5],
59 organizeMinGroupSize: [2, 5],
55 parkAfterHours: [1, 720],
60 parkAfterHours: [1, 720],
src/background/state.ts+6
@@ -18,6 +18,8 @@ export interface TabRecord {
18 createdAt: number;
18 createdAt: number;
19 lastActivatedAt: number; // epoch ms
19 lastActivatedAt: number; // epoch ms
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
22+ organizedKey?: string; // page + title when auto-organize last considered this tab
21}
23}
22
24
23export interface GroupRecord {
25export interface GroupRecord {
@@ -113,6 +115,8 @@ export interface State {
113 ownWrites: Record<number, { title?: string; color?: string; at: number }>;
115 ownWrites: Record<number, { title?: string; color?: string; at: number }>;
114 /** Groups the worker is about to create: onCreated within 2 s in that window is ours. */
116 /** Groups the worker is about to create: onCreated within 2 s in that window is ours. */
115 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
117 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>;
116 lastSweep?: { at: number; moves: SweepMove[] };
120 lastSweep?: { at: number; moves: SweepMove[] };
117 lastOrganize?: { at: number; previous: Record<number, number> };
121 lastOrganize?: { at: number; previous: Record<number, number> };
118 tidyCandidates?: number;
122 tidyCandidates?: number;
@@ -126,6 +130,7 @@ export const emptyState = (): State => ({
126 host: { consecutiveFailures: 0 },
130 host: { consecutiveFailures: 0 },
127 ownWrites: {},
131 ownWrites: {},
128 pendingCreates: [],
132 pendingCreates: [],
133+ ownUngroups: {},
129});
134});
130
135
131/** Accept whatever was stored and bring it to the current shape. */
136/** Accept whatever was stored and bring it to the current shape. */
@@ -145,6 +150,7 @@ export function migrate(raw: unknown): State {
145 host: { consecutiveFailures: 0, ...(r.host ?? {}) },
150 host: { consecutiveFailures: 0, ...(r.host ?? {}) },
146 ownWrites: r.ownWrites ?? {},
151 ownWrites: r.ownWrites ?? {},
147 pendingCreates: r.pendingCreates ?? [],
152 pendingCreates: r.pendingCreates ?? [],
153+ ownUngroups: r.ownUngroups ?? {},
148 };
154 };
149}
155}
150
156
src/background/tidy.ts+2−1
@@ -120,7 +120,8 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
120 let tabs = await liveTabs();
120 let tabs = await liveTabs();
121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122 report.candidates = candidates.length;
122 report.candidates = candidates.length;
123+ // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
123− const park = manual || (settings.tidyMode === "auto" && candidates.length >= settings.tidyThreshold);
124+ const park = manual || settings.tidyMode === "auto";
124 if (park && candidates.length) {
125 if (park && candidates.length) {
125 report.parked = await parkTabs(rt, candidates);
126 report.parked = await parkTabs(rt, candidates);
126 tabs = await liveTabs();
127 tabs = await liveTabs();
src/options/options.ts+6−4
@@ -11,8 +11,10 @@ type Field =
11
11
12const SECTIONS: [string, Field[]][] = [
12const SECTIONS: [string, Field[]][] = [
13 ["Grouping", [
13 ["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 },
14 { key: "openerGrouping", label: "Group a tab with the tab it was opened from", kind: "bool" },
16 { 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" },
16 ]],
18 ]],
17 ["Naming", [
19 ["Naming", [
18 { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
20 { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
@@ -23,13 +25,13 @@ const SECTIONS: [string, Field[]][] = [
23 { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
25 { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
24 { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
26 { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
25 { key: "timeoutMs", label: "Timeout per model call", kind: "number", min: 5, max: 120, step: 1, unit: "s", scale: 1000 },
27 { 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" },
27 ]],
29 ]],
28 ["Tidy", [
30 ["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"]] },
30 { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
32 { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
31 { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 = never archive.", kind: "number", min: 0, max: 2160, unit: "h" },
33 { 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" },
33 { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
35 { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
34 { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
36 { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
35 { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Write /…/ for a regular expression.", kind: "list" },
37 { 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 @@
21 <div id="notice" class="notice" hidden></div>
21 <div id="notice" class="notice" hidden></div>
22
22
23 <div class="actions">
23 <div class="actions">
24− <button class="primary" id="organize">Organize this window</button>
24+ <button id="organize">Organize now</button>
25 <button id="tidy">Tidy now</button>
25 <button id="tidy">Tidy now</button>
26 <button id="park" hidden></button>
26 <button id="park" hidden></button>
27 </div>
27 </div>
src/popup/popup.ts+1−1
@@ -136,7 +136,7 @@ async function main(): Promise<void> {
136 b.textContent = "Organizing…";
136 b.textContent = "Organizing…";
137 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
137 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
138 b.disabled = false;
138 b.disabled = false;
139− b.textContent = "Organize this window";
139+ b.textContent = "Organize now";
140 if (r) showResult(`${r.grouped} tabs grouped into ${r.groups} new group${r.groups === 1 ? "" : "s"}, ${r.left} left${r.error ? ` (stopped: ${r.error})` : ""}.`);
140 if (r) showResult(`${r.grouped} tabs grouped into ${r.groups} new group${r.groups === 1 ? "" : "s"}, ${r.left} left${r.error ? ` (stopped: ${r.error})` : ""}.`);
141 };
141 };
142 const tidy = async () => {
142 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 @@
1// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/diagonal-host.py)
1// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/diagonal-host.py)
2// wired to a keyword-driven fake fm, then drives opener grouping, naming, a user rename, dissolve,
2// 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).
4// node test/e2e/smoke.mjs (after npm run build)
4// node test/e2e/smoke.mjs (after npm run build)
5import { chromium } from "playwright-core";
5import { chromium } from "playwright-core";
6import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
6import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
@@ -90,8 +90,9 @@ try {
90 }
90 }
91 };
91 };
92
92
93− // Settings: short debounce so the test is quick.
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.
94− await sw.evaluate(async () => chrome.storage.local.set({ settings: { namingDebounceMs: 1000 } }));
95+ await sw.evaluate(async () => chrome.storage.local.set({ settings: { namingDebounceMs: 1000, autoOrganize: false } }));
95
96
96 // Wait for boot's ping.
97 // Wait for boot's ping.
97 const pinged = await until(() => sw.evaluate(async () => (await chrome.storage.local.get("state")).state?.host?.lastPing?.fmAvailable === true));
98 const pinged = await until(() => sw.evaluate(async () => (await chrome.storage.local.get("state")).state?.host?.lastPing?.fmAvailable === true));
@@ -157,7 +158,35 @@ try {
157 const afterUndo = await sw.evaluate(async (w) => (await chrome.tabGroups.query({ windowId: w })).length, win);
158 const afterUndo = await sw.evaluate(async (w) => (await chrome.tabGroups.query({ windowId: w })).length, win);
158 check("Undo organize restores the previous layout", undone?.result === 4 && afterUndo === 0, JSON.stringify({ undone, afterUndo }));
159 check("Undo organize restores the previous layout", undone?.result === 4 && afterUndo === 0, JSON.stringify({ undone, afterUndo }));
159
160
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+
160− // 6. Popup renders the healthy state.
189+ // 7. Popup renders the healthy state.
161 await popup.setViewportSize({ width: 360, height: 560 });
190 await popup.setViewportSize({ width: 360, height: 560 });
162 await popup.reload();
191 await popup.reload();
163 await sleep(800);
192 await sleep(800);
@@ -174,7 +203,7 @@ try {
174 await options.setViewportSize({ width: 900, height: 1400 });
203 await options.setViewportSize({ width: 900, height: 1400 });
175 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
204 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
176
205
177− // 7. Host missing → HOST_NOT_FOUND surfaced.
206+ // 8. Host missing → HOST_NOT_FOUND surfaced.
178 writeFileSync(join(profile, "NativeMessagingHosts/io.diagonal.host.json"), "{}");
207 writeFileSync(join(profile, "NativeMessagingHosts/io.diagonal.host.json"), "{}");
179 writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.diagonal.host.json"), "{}");
208 writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.diagonal.host.json"), "{}");
180 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
209 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
test/engine.test.ts+74−6
@@ -13,10 +13,10 @@ describe("opener rule", () => {
13 ]);
13 ]);
14 });
14 });
15
15
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", () => {
17 const opener = tab(1, { pinned: true });
17 const opener = tab(1, { pinned: true });
18 const { actions } = step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, ctx());
18 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 }]);
20 });
20 });
21
21
22 it("skips pinned, blank and internal children", () => {
22 it("skips pinned, blank and internal children", () => {
@@ -44,7 +44,8 @@ describe("opener rule", () => {
44
44
45 it("does nothing when opener grouping is off", () => {
45 it("does nothing when opener grouping is off", () => {
46 const opener = tab(1);
46 const opener = tab(1);
47+ const off = ctx(0, { openerGrouping: false, autoOrganize: false });
47− expect(step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, ctx(0, { openerGrouping: false })).actions).toEqual([]);
48+ expect(step(stateWith([opener]), { type: "tabCreated", tab: tab(2, { openerTabId: 1 }), opener }, off).actions).toEqual([]);
48 });
49 });
49
50
50 it("ignores incognito tabs entirely", () => {
51 it("ignores incognito tabs entirely", () => {
@@ -72,11 +73,22 @@ describe("dissolve rule", () => {
72 expect(step(s, { type: "dissolveCheck", groupId: 10, memberIds: [1, 3] }, ctx()).actions).toEqual([]);
73 expect(step(s, { type: "dissolveCheck", groupId: 10, memberIds: [1, 3] }, ctx()).actions).toEqual([]);
73 });
74 });
74
75
75− it("organize groups shrink to one member without dissolving", () => {
76+ it("organize groups dissolve at one member too", () => {
76 const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
77 const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
77 const r1 = step(s, { type: "tabRemoved", tabId: 2 }, ctx());
78 const r1 = step(s, { type: "tabRemoved", tabId: 2 }, ctx());
78− expect(r1.actions.some((a) => a.type === "scheduleDissolve")).toBe(false);
79+ expect(r1.actions).toContainEqual({ type: "scheduleDissolve", groupId: 10, delayMs: DISSOLVE_DELAY_MS });
79− expect(step(r1.state, { type: "dissolveCheck", groupId: 10, memberIds: [1] }, ctx()).actions).toEqual([]);
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([]);
80 });
92 });
81
93
82 it("user groups are never dissolved", () => {
94 it("user groups are never dissolved", () => {
@@ -184,3 +196,59 @@ describe("dirty triggers", () => {
184 expect(JSON.stringify(s)).toBe(before);
196 expect(JSON.stringify(s)).toBe(before);
185 });
197 });
186});
198});
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", () => {
80 it("fills defaults and clamps", () => {
80 it("fills defaults and clamps", () => {
81 const s = withDefaults({ namingDebounceMs: 10, tidyMode: "weird", emoji: false, tidyExclusions: ["a", "", 3] });
81 const s = withDefaults({ namingDebounceMs: 10, tidyMode: "weird", emoji: false, tidyExclusions: ["a", "", 3] });
82 expect(s.namingDebounceMs).toBe(1000);
82 expect(s.namingDebounceMs).toBe(1000);
83− expect(s.tidyMode).toBe("ask");
83+ expect(s.tidyMode).toBe("auto");
84 expect(s.emoji).toBe(false);
84 expect(s.emoji).toBe(false);
85 expect(s.tidyExclusions).toEqual(["a"]);
85 expect(s.tidyExclusions).toEqual(["a"]);
86 expect(withDefaults(undefined).parkAfterHours).toBe(24);
86 expect(withDefaults(undefined).parkAfterHours).toBe(24);