on master help sign in
Keep Diagonal's groups its own across restarts and window moves; fix Parked timing

After a browser restart every Diagonal group, Parked included, came back as an ordinary group: the restored groups' events were handled before the state was matched to the new ids, and their stale "untitled" snapshots read as the user clearing the title. Moving a group to another window did the same. Now reconcile runs first, group events are read when handled, removed groups are kept for a day so a moved or reopened group comes back as itself, and a Parked group an older version lost is adopted again. Tidy fixes: - A tab sits in Parked for the archive time before it is closed, counted from when it was parked (it used to be parked and closed in one sweep). - After a restart, tabs never looked at keep their idle time. - Groups you renamed are not tidied, like groups you made. - Sweeps run one at a time; undo covers every sweep in its hour, makes an emptied group again, and does not mark restored tabs as pulled out. - A tab marked "never discard" is not discarded; Never tidy also protects tabs already in Parked. Also: a new tab made inside a group is yours there; a tab whose first load redirects is not fit-checked; failed grouping calls clean up after themselves; "Name groups I make too" applies to existing groups; a Parked title you typed survives the emoji switch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commit39441a5f6b38f332f3f6faa106c3420a17981844 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 11:37 (2 weeks ago) parentfae43a2
8 files changed+689−51
src/background/engine.ts+98−4 src/background/index.ts+94−26 src/background/runtime.ts+13−2 src/background/state.ts+15 src/background/tidy.ts+128−16 test/engine.test.ts+126 test/sweep.test.ts+181 test/tidy.test.ts+34−3
src/background/engine.ts+98−4
@@ -2,7 +2,7 @@ import type { GroupColor } from "../shared/colors";
22import { isColor } from "../shared/colors";
33import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
44import type { Settings } from "./settings";
5−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
5+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
66
77/**
88 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -61,6 +61,10 @@ export interface Ctx {
6161export const DISSOLVE_DELAY_MS = 1500;
6262export const OWN_CREATE_WINDOW_MS = 2000;
6363export const OWN_WRITE_WINDOW_MS = 60_000;
64+/** A group moved to another window is removed and recreated with the same id within milliseconds. */
65+export const REVIVE_WINDOW_MS = 10_000;
66+export const REMOVED_KEEP_MS = 24 * 3_600_000;
67+export const REMOVED_CAP = 20;
6468
6569/** The pure form: never touches `prev`. */
6670export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -85,8 +89,16 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
8589const handlers: Handlers = {
8690 tabCreated(s, { tab, opener }, ctx, out) {
8791 if (tab.incognito) return;
92+ const known = !!s.tabs[tab.id];
8893 const rec = upsertTab(s, tab, ctx.now);
89− if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
94+ if (rec.groupId !== -1) {
95+ // Born inside a group with no page that led to it ("New tab in group"): the user put it there.
96+ // A link opened from a grouped tab has an opener and stays Diagonal's to check.
97+ const g = s.groups[rec.groupId];
98+ const newPage = tab.openerTabId === undefined || isInternalUrl(tab.pendingUrl || tab.url);
99+ if (!known && newPage && g && !revivedLately(g, ctx.now)) rec.handPlaced = true;
100+ dirty(s, rec.groupId, ctx, out);
101+ }
90102 const before = out.length;
91103 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
92104 if (out.length === before) loose(rec, ctx, out);
@@ -106,8 +118,18 @@ const handlers: Handlers = {
106118 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
107119 if (before.groupId !== rec.groupId) {
108120 left(s, before.groupId, ctx, out);
121+ if (inTransit(s, before.groupId, rec.groupId, ctx.now)) {
122+ // Chromium moves a group to another window by removing it, ungrouping its tabs and grouping
123+ // them again under the same id: nobody chose anything, so the tab keeps what it had.
124+ delete s.ownAdds[rec.id];
125+ if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
126+ else loose(rec, ctx, out);
127+ return;
128+ }
109129 rec.fitPending = undefined;
130+ if (s.groups[before.groupId]?.origin === "tidy") rec.parkedAt = undefined; // out of Parked
110131 if (rec.groupId !== -1) {
132+ rec.keepLoose = undefined; // in a group now: there is no loose choice left to protect
111133 // Into a group: ours if the worker put it there, otherwise the user did and it stays.
112134 if (s.ownAdds[rec.id] !== undefined) {
113135 delete s.ownAdds[rec.id];
@@ -135,7 +157,9 @@ const handlers: Handlers = {
135157 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
136158 return;
137159 }
138− if (pathChanged && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
160+ // Only a tab that had finished a page moves on: a new tab's first load redirecting (http to https,
161+ // a link shortener) is still its first page.
162+ if (pathChanged && before.status === "complete" && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
139163 if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
140164 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
141165 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
@@ -160,6 +184,7 @@ const handlers: Handlers = {
160184 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
161185 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
162186 rec.parkedFrom = undefined;
187+ rec.parkedAt = undefined;
163188 out.push({ type: "unpark", tabId, groupId: rec.groupId });
164189 }
165190 },
@@ -174,6 +199,15 @@ const handlers: Handlers = {
174199 registerGroup(s, group, ctx, out);
175200 return;
176201 }
202+ // A group Chromium restores (a reopened window, the session after a restart) is created untitled
203+ // and gets its title a moment later: that title and colour can say it is one of Diagonal's back.
204+ if (rec.registeredAt !== undefined && ctx.now - rec.registeredAt <= REVIVE_WINDOW_MS) {
205+ const back = buried(s, group.title ?? "", group.color);
206+ if (back) {
207+ revive(s, back, group, ctx);
208+ return;
209+ }
210+ }
177211 rec.windowId = group.windowId;
178212 const own = s.ownWrites[group.id];
179213 const title = group.title ?? "";
@@ -201,9 +235,11 @@ const handlers: Handlers = {
201235 }
202236 },
203237
204− groupRemoved(s, { groupId }) {
238+ groupRemoved(s, { groupId }, ctx) {
239+ const rec = s.groups[groupId];
205240 delete s.groups[groupId];
206241 delete s.ownWrites[groupId];
242+ if (rec) bury(s, rec, ctx.now);
207243 },
208244
209245 meta(s, { tabId, description }, ctx, out) {
@@ -306,16 +342,64 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
306342 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
307343 return;
308344 }
345+ // Moved to another window (same id, just removed) or reopened (same title and colour): the same group.
346+ const moved = s.removedGroups[group.id];
347+ const back = moved && ctx.now - moved.removedAt <= REVIVE_WINDOW_MS ? moved : buried(s, group.title ?? "", group.color);
348+ if (back) {
349+ revive(s, back, group, ctx);
350+ return;
351+ }
309352 const titled = !!group.title;
310353 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
311354 managed: ctx.settings.nameUserGroups,
312355 userNamed: titled,
313356 stripTitle: group.title ?? "",
357+ registeredAt: ctx.now,
314358 });
315359 s.groups[group.id] = rec;
316360 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
317361}
318362
363+/** Keep a removed group's record for a while, so the group is still Diagonal's if Chromium brings it back. */
364+export function bury(s: State, rec: GroupRecord, now: number): void {
365+ s.removedGroups[rec.id] = { ...rec, removedAt: now };
366+ pruneRemoved(s, now);
367+}
368+
369+/** The most recently removed group with this title and colour, if any. */
370+function buried(s: State, title: string, color: string): RemovedGroup | undefined {
371+ if (!title) return undefined;
372+ let best: RemovedGroup | undefined;
373+ for (const g of Object.values(s.removedGroups)) {
374+ if ((g.stripTitle ?? "") === title && g.color === color && (!best || g.removedAt > best.removedAt)) best = g;
375+ }
376+ return best;
377+}
378+
379+function revive(s: State, back: RemovedGroup, group: GroupSnapshot, ctx: Ctx): void {
380+ const { removedAt: _, registeredAt: __, ...rec } = back;
381+ delete s.removedGroups[back.id];
382+ delete s.groups[group.id];
383+ s.groups[group.id] = {
384+ ...rec,
385+ id: group.id,
386+ windowId: group.windowId,
387+ color: isColor(group.color) ? group.color : rec.color,
388+ // An untitled restore gets its title next: keep the known one so that update is not read as an edit.
389+ stripTitle: group.title || rec.stripTitle,
390+ revivedAt: ctx.now,
391+ };
392+}
393+
394+const revivedLately = (g: GroupRecord | undefined, now: number): boolean => !!g?.revivedAt && now - g.revivedAt <= REVIVE_WINDOW_MS;
395+
396+/** Leaving a group Chromium just removed, or joining one it is bringing back: a move, not a choice. */
397+function inTransit(s: State, from: number, to: number, now: number): boolean {
398+ const gone = (id: number) => !s.groups[id] && !!s.removedGroups[id];
399+ if (to === -1) return from !== -1 && gone(from);
400+ return from === -1 && (gone(to) || revivedLately(s.groups[to], now));
401+}
402+
319403function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
320404 const before = s.tabs[tab.id];
321405 const url = tab.url || tab.pendingUrl || "";
@@ -336,6 +420,8 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
336420 // A description belongs to a page: keep it only while the tab stays on that page.
337421 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
338422 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
423+ if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
424+ if (before?.parkedAt) rec.parkedAt = before.parkedAt;
339425 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
340426 if (before?.handPlaced) rec.handPlaced = true;
341427 if (before?.fitPending) rec.fitPending = true;
@@ -362,4 +448,12 @@ function prune(s: State, now: number): void {
362448 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
363449 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
364450 for (const [id, at] of Object.entries(s.ownAdds)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownAdds[+id];
451+ pruneRemoved(s, now);
452+}
453+
454+function pruneRemoved(s: State, now: number): void {
455+ const removed = Object.values(s.removedGroups).sort((a, b) => b.removedAt - a.removedAt);
456+ removed.forEach((g, i) => {
457+ if (i >= REMOVED_CAP || now - g.removedAt > REMOVED_KEEP_MS) delete s.removedGroups[g.id];
458+ });
365459}
src/background/index.ts+94−26
@@ -1,14 +1,14 @@
11import { stripTitle } from "../shared/label";
2−import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2+import { applyEvent, bury, 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";
55import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
66import { FitChecker } from "./fit";
77import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
88import { withDefaults, type Settings } from "./settings";
9−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
1010import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
11−import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
1212
1313/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
1414
@@ -20,6 +20,8 @@ const ALARM_HOST = "host-retry";
2020const ALARM_AUTO = "auto-organize";
2121const ALARM_UPDATE = "self-update";
2222const HOST_RETRY_MS = 600_000;
23+/** Records made after this were made by this worker, so after a restart they describe the new session. */
24+const WORKER_STARTED_AT = Date.now();
2325
2426// ----- state cache ------------------------------------------------------------------------------
2527
@@ -346,16 +348,24 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
346348 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
347349 * (groups), which keeps managed groups managed after the browser restarts.
348350 */
349−async function reconcile(): Promise<void> {
351+async function reconcile(reason: "startup" | "installed"): Promise<void> {
350352 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
351353 const now = Date.now();
352− const oldTabs = Object.values(state.tabs);
353− const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
354+ const liveTabIds = new Set(tabs.map((t) => t.id));
355+ // Records of tabs that are gone, by address: after a restart every tab has a new id.
356+ const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
354357 const nextTabs: Record<number, TabRecord> = {};
355358 for (const t of tabs) {
356359 if (t.incognito || t.id === undefined) continue;
357360 const url = t.url || t.pendingUrl || "";
358− const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
361+ const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
362+ // An event handled before this ran may have recorded a restored tab as new: its old record knows more.
363+ const old = byUrl.get(url);
364+ const prev = old && (!same || same.createdAt >= WORKER_STARTED_AT) ? old : same;
365+ // Chromium stamps a restored tab it has not shown yet with the restore time, so after a restart the
366+ // stored time is the truer one for every tab but the one on screen.
367+ const lastSeen =
368+ reason === "startup" && prev && !t.active ? prev.lastActivatedAt : Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0);
359369 nextTabs[t.id] = {
360370 id: t.id,
361371 windowId: t.windowId,
@@ -368,25 +378,50 @@ async function reconcile(): Promise<void> {
368378 openerTabId: t.openerTabId,
369379 description: prev?.description,
370380 parkedFrom: prev?.parkedFrom,
381+ parkedAt: prev?.parkedAt,
371382 keepLoose: prev?.keepLoose,
372383 organizedKey: prev?.organizedKey,
373384 handPlaced: prev?.handPlaced,
385+ fitPending: prev?.fitPending,
374386 createdAt: prev?.createdAt ?? now,
375− lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
387+ lastActivatedAt: lastSeen || now,
376388 };
377389 }
390+ const liveGroupIds = new Set(groups.map((g) => g.id));
378391 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
392+ // Made by this worker for a group it did not know: after a restart, that is a restored group whose
393+ // events were handled before this ran, and the record from before the restart is the one to keep.
394+ const fresh = (g: GroupRecord) => g.origin === "user" && (g.registeredAt ?? 0) >= WORKER_STARTED_AT;
395+ const lookalike = (title: string, color: string): GroupRecord | RemovedGroup | undefined => {
396+ if (title === "") return undefined;
397+ const alike = (o: GroupRecord) => (o.stripTitle ?? "") === title && o.color === color;
398+ return (
399+ [...unmatched.values()].find((o) => alike(o) && !liveGroupIds.has(o.id) && !fresh(o)) ??
400+ Object.values(state.removedGroups)
401+ .filter(alike)
402+ .sort((a, b) => b.removedAt - a.removedAt)[0]
403+ );
404+ };
379405 const nextGroups: Record<number, GroupRecord> = {};
380406 for (const g of groups) {
381407 const title = g.title ?? "";
382− let prev = unmatched.get(g.id);
383− if (!prev || (prev.stripTitle ?? "") !== title) {
384− prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
408+ let prev: GroupRecord | undefined = unmatched.get(g.id);
409+ if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
410+ const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
411+ if (before) prev = before;
412+ let rec: GroupRecord;
413+ if (prev) {
414+ unmatched.delete(prev.id);
415+ delete state.removedGroups[prev.id];
416+ const { removedAt: _, ...kept } = prev as RemovedGroup;
417+ rec = { ...kept, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title };
418+ if (before) rec.registeredAt = undefined;
419+ } else {
420+ // Untitled so far may still mean restored: its title can arrive next and match (engine.ts groupUpdated).
421+ rec = newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title, registeredAt: now });
385422 }
386− if (prev) unmatched.delete(prev.id);
387− const rec: GroupRecord = prev
388− ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
389− : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
423+ // Earlier versions turned Parked into a plain group after a restart: give it back its job.
424+ if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
390425 if (rec.managed) {
391426 rec.dirty = true;
392427 rec.dirtyAt = 0;
@@ -394,14 +429,17 @@ async function reconcile(): Promise<void> {
394429 }
395430 nextGroups[g.id] = rec;
396431 }
432+ // Groups not seen now (a closed window, a quit that did not save) may still come back: keep them a day.
433+ for (const old of unmatched.values()) if (!liveGroupIds.has(old.id) && !fresh(old)) bury(state, old, now);
397434 state.tabs = nextTabs;
398435 state.groups = nextGroups;
399436 state.pendingCreates = [];
400437 state.ownWrites = {};
401438 state.ownUngroups = {};
402439 state.ownAdds = {};
403− if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
404− if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
440+ // Undo lists name tabs by id, and after a restart none of those ids exist.
441+ if (reason === "startup" || (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS)) state.lastSweep = undefined;
442+ if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
405443 commit();
406444}
407445
@@ -414,9 +452,13 @@ async function injectBacklog(): Promise<void> {
414452 }
415453}
416454
455+let reconciled: Promise<void> | undefined;
456+
417457async function boot(reason: "startup" | "installed"): Promise<void> {
418− await ready;
419− await serial("reconcile", reconcile);
458+ // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
459+ // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
460+ reconciled = serial("reconcile", () => reconcile(reason));
461+ await reconciled;
420462 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
421463 if (reason === "installed") void injectBacklog();
422464 await ping();
@@ -470,8 +512,15 @@ if (GROUPS_SUPPORTED) {
470512
471513 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
472514
473− chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
474− chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
515+ // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
516+ // untitled and then titled, and replaying the stale "untitled" would read as you clearing the title.
517+ const refetchGroup = (where: string, type: "groupCreated" | "groupUpdated") => (g: chrome.tabGroups.TabGroup) =>
518+ void serial(where, async () => {
519+ const now = await chrome.tabGroups.get(g.id).catch(() => undefined);
520+ if (now) await feed({ type, group: snapGroup(now) });
521+ });
522+ chrome.tabGroups.onCreated.addListener(refetchGroup("tabGroups.onCreated", "groupCreated"));
523+ chrome.tabGroups.onUpdated.addListener(refetchGroup("tabGroups.onUpdated", "groupUpdated"));
475524 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
476525}
477526
@@ -500,7 +549,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
500549 await ready;
501550 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
502551 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
503− else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
552+ else if (alarm.name === ALARM_TIDY) {
553+ await reconciled; // an overdue sweep right after a restart waits for the new tab and group ids
554+ await runSweep(rt).catch((e) => logError("tidy", e));
555+ }
504556 else if (alarm.name === ALARM_HOST) {
505557 const r = await ping();
506558 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -514,18 +566,34 @@ chrome.storage.onChanged.addListener((changes, area) => {
514566 await ready;
515567 const before = settings;
516568 settings = withDefaults(changes.settings.newValue);
517− if (before.emoji !== settings.emoji) await rewriteTitles();
569+ if (before.emoji !== settings.emoji) await rewriteTitles(before);
570+ if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
518571 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
519572 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
520573 refreshBadge();
521574 })();
522575});
523576
577+/** "Name groups I make too" applies to the groups you already made, not only to new ones. */
578+function applyNameUserGroups(): void {
579+ for (const g of Object.values(state.groups)) {
580+ if (g.origin !== "user" || g.userNamed) continue;
581+ g.managed = settings.nameUserGroups;
582+ if (g.managed) {
583+ markDirty(g, Date.now());
584+ naming.touch(g.id);
585+ }
586+ }
587+ commit();
588+}
589+
524590/** Emoji switched on or off: rewrite the titles the extension wrote. */
525−async function rewriteTitles(): Promise<void> {
591+async function rewriteTitles(before: Settings): Promise<void> {
526592 for (const g of Object.values(state.groups)) {
527− if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
528− else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
593+ // Parked is always "user named", so its title is compared instead: one you typed stays.
594+ if (g.origin === "tidy") {
595+ if ((g.stripTitle ?? "") === parkedTitle(before)) await updateGroup(rt, g.id, { title: parkedTitle(settings) });
596+ } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
529597 }
530598}
531599
src/background/runtime.ts+13−2
@@ -67,20 +67,25 @@ export async function createManagedGroup(
6767 extra: Partial<GroupRecord> = {},
6868): Promise<number | undefined> {
6969 const s = rt.state();
70− s.pendingCreates.push({ windowId, origin, at: rt.now() });
70+ const pending = { windowId, origin, at: rt.now() };
71+ s.pendingCreates.push(pending);
7172 for (const id of tabIds) s.ownAdds[id] = rt.now();
7273 let groupId: number;
7374 try {
7475 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
7576 } catch (e) {
7677 rt.log("tabs.group failed", e);
78+ forgetOwnAdds(rt.state(), tabIds);
79+ const live = rt.state();
80+ live.pendingCreates = live.pendingCreates.filter((p) => p !== pending);
7781 return undefined;
7882 }
7983 const live = rt.state();
8084 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
8185 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
8286 live.groups[groupId] = rec;
83− for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
87+ // Tab records move when tabs.onUpdated reports the new groupId: that event also tells the group
88+ // each tab left that it lost one, and uses up the ownAdds marker.
8489 await updateGroup(rt, groupId, props);
8590 rt.commit();
8691 return groupId;
@@ -94,10 +99,16 @@ export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number)
9499 return true;
95100 } catch (e) {
96101 rt.log("tabs.group into existing failed", e);
102+ forgetOwnAdds(rt.state(), tabIds);
97103 return false;
98104 }
99105}
100106
107+/** A grouping call failed: nothing moved, so a later move of these tabs is not the worker's. */
108+function forgetOwnAdds(s: State, tabIds: number[]): void {
109+ for (const id of tabIds) delete s.ownAdds[id];
110+}
111+
101112export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
102113 if (!tabIds.length) return;
103114 try {
src/background/state.ts+15
@@ -18,6 +18,7 @@ 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+ parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
2122 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2223 organizedKey?: string; // page + title when auto-organize last considered this tab
2324 handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
@@ -45,8 +46,13 @@ export interface GroupRecord {
4546 nameAttempts: number;
4647 firstFailureAt?: number;
4748 nextAttemptAt?: number;
49+ registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
50+ revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
4851}
4952
53+/** A group Chromium removed, kept for a day: it may come back (moved to another window, reopened, restored). */
54+export type RemovedGroup = GroupRecord & { removedAt: number };
55+
5056export interface ArchivedTab {
5157 url: string;
5258 title: string;
@@ -107,6 +113,11 @@ export interface SweepMove {
107113 groupId: number;
108114 index: number;
109115 windowId: number;
116+ /** The group the tab was in, so undo can make it again if parking emptied it. */
117+ group?: Pick<
118+ GroupRecord,
119+ "origin" | "managed" | "userNamed" | "title" | "emoji" | "stripTitle" | "color" | "colorLocked" | "keep" | "membersHash" | "memberUrls" | "lastNamedAt"
120+ >;
110121}
111122
112123export interface State {
@@ -124,6 +135,8 @@ export interface State {
124135 ownUngroups: Record<number, number>;
125136 /** Tabs the worker itself is adding to a group: joining it is not the user's choice. */
126137 ownAdds: Record<number, number>;
138+ /** Groups Chromium removed lately, by their last id. */
139+ removedGroups: Record<number, RemovedGroup>;
127140 lastSweep?: { at: number; moves: SweepMove[] };
128141 lastOrganize?: { at: number; previous: Record<number, number> };
129142 tidyCandidates?: number;
@@ -139,6 +152,7 @@ export const emptyState = (): State => ({
139152 pendingCreates: [],
140153 ownUngroups: {},
141154 ownAdds: {},
155+ removedGroups: {},
142156});
143157
144158/** Accept whatever was stored and bring it to the current shape. */
@@ -160,6 +174,7 @@ export function migrate(raw: unknown): State {
160174 pendingCreates: r.pendingCreates ?? [],
161175 ownUngroups: r.ownUngroups ?? {},
162176 ownAdds: r.ownAdds ?? {},
177+ removedGroups: r.removedGroups ?? {},
163178 };
164179}
165180
src/background/tidy.ts+128−16
@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
33import { isInternalUrl } from "../shared/url";
44import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
55import type { Settings } from "./settings";
6−import type { ArchivedTab, State, SweepMove } from "./state";
6+import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
77
88/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
99
@@ -23,11 +23,22 @@ export interface TidyTab {
2323 audible?: boolean;
2424 incognito?: boolean;
2525 discarded?: boolean;
26+ autoDiscardable?: boolean;
2627 lastAccessed?: number;
2728}
2829
30+/**
31+ * The engine's record wins over Chromium's lastAccessed: after a restart Chromium stamps restored tabs
32+ * nobody has looked at yet with the restore time, which would keep them from ever going stale.
33+ */
2934export const idleMs = (tab: TidyTab, state: State, now: number): number =>
30− now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
35+ now - (state.tabs[tab.id]?.lastActivatedAt ?? tab.lastAccessed ?? now);
36+
37+/** How long a tab has sat in Parked. Tabs found there with no parking time start their clock now. */
38+export const parkedMs = (tab: TidyTab, state: State, now: number): number => {
39+ const at = state.tabs[tab.id]?.parkedAt;
40+ return at === undefined ? 0 : now - at;
41+};
3142
3243/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
3344export function excluded(url: string, patterns: string[]): boolean {
@@ -55,7 +66,8 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
5566 if (tab.groupId !== -1) {
5667 const g = state.groups[tab.groupId];
5768 if (g?.keep) return false;
58− if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
69+ // Groups you made or renamed are yours, like Diagonal's other rules treat them.
70+ if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
5971 }
6072 if (excluded(tab.url, settings.tidyExclusions)) return false;
6173 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -66,10 +78,18 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
6678 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
6779}
6880
81+/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
6982export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
7083 if (!settings.archiveAfterHours) return [];
7184 const parking = parkingGroupIds(state);
72− return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
85+ return tabs.filter(
86+ (t) =>
87+ parking.has(t.groupId) &&
88+ !t.active &&
89+ !t.audible &&
90+ !(t.url && excluded(t.url, settings.tidyExclusions)) &&
91+ parkedMs(t, state, now) >= settings.archiveAfterHours * HOUR,
92+ );
7393}
7494
7595/** Prepend entries newest first and drop the oldest past the cap. */
@@ -79,6 +99,15 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
7999
80100export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
81101
102+/** A grey group titled like Parked: Diagonal's Parked group, even where an older version lost track of it. */
103+export const looksParked = (g: { title?: string; color: string }, settings: Settings): boolean =>
104+ g.color === "grey" && (g.title ?? "") === parkedTitle(settings);
105+
106+/** Make a group Diagonal's Parked group again. */
107+export function adoptParked(g: GroupRecord, settings: Settings): void {
108+ Object.assign(g, { origin: "tidy", managed: true, userNamed: true, stripTitle: parkedTitle(settings) });
109+}
110+
82111// ---------------------------------------------------------------------------------------------
83112
84113const liveTabs = async (): Promise<TidyTab[]> =>
@@ -95,6 +124,7 @@ const liveTabs = async (): Promise<TidyTab[]> =>
95124 audible: t.audible,
96125 incognito: t.incognito,
97126 discarded: t.discarded,
127+ autoDiscardable: t.autoDiscardable,
98128 lastAccessed: t.lastAccessed,
99129 }));
100130
@@ -104,11 +134,20 @@ export interface SweepReport {
104134 archived: number;
105135}
106136
137+let sweeps: Promise<unknown> = Promise.resolve();
138+
107139/**
108140 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
109− * consent, still respects eligibility).
141+ * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142+ * same tabs twice.
110143 */
111−export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
144+export function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
145+ const next = sweeps.then(() => sweep(rt, manual));
146+ sweeps = next.catch(() => undefined);
147+ return next;
148+}
149+
150+async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
112151 const settings = rt.settings();
113152 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
114153 if (settings.tidyMode === "off" && !manual) {
@@ -118,6 +157,7 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
118157 }
119158 const now = rt.now();
120159 let tabs = await liveTabs();
160+ startParkedClocks(rt.state(), tabs, now);
121161 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122162 report.candidates = candidates.length;
123163 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -133,8 +173,52 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
133173 return report;
134174}
135175
176+/** Tabs already in Parked with no parking time (from before it was recorded) start their archive clock now. */
177+function startParkedClocks(s: State, tabs: TidyTab[], now: number): void {
178+ const parking = parkingGroupIds(s);
179+ for (const t of tabs) {
180+ const rec = s.tabs[t.id];
181+ if (rec && parking.has(t.groupId) && rec.parkedAt === undefined) rec.parkedAt = now;
182+ }
183+}
184+
185+/** What undo needs to make a group again if parking emptied it, named as it was. */
186+const groupShape = (g: GroupRecord): SweepMove["group"] => ({
187+ origin: g.origin,
188+ managed: g.managed,
189+ userNamed: g.userNamed,
190+ title: g.title,
191+ emoji: g.emoji,
192+ stripTitle: g.stripTitle,
193+ color: g.color,
194+ colorLocked: g.colorLocked,
195+ keep: g.keep,
196+ membersHash: g.membersHash,
197+ memberUrls: g.memberUrls,
198+ lastNamedAt: g.lastNamedAt,
199+});
200+
201+/**
202+ * The window's Parked group: the one on record, or a group already showing the Parked title (one an
203+ * older version lost track of after a restart), which becomes Parked again.
204+ */
205+async function parkingGroupIn(rt: Runtime, windowId: number): Promise<number | undefined> {
206+ const s = rt.state();
207+ const known = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
208+ if (known !== undefined && (await groupExists(known))) return known;
209+ const settings = rt.settings();
210+ const shown = (await chrome.tabGroups.query({ windowId }).catch(() => [])).filter((g) => looksParked(g, settings));
211+ for (const g of shown) {
212+ const rec = s.groups[g.id] ?? newGroupRecord(g.id, windowId, "tidy", "grey");
213+ adoptParked(rec, settings);
214+ s.groups[g.id] = rec;
215+ }
216+ return shown[0]?.id;
217+}
218+
136219async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
137220 const settings = rt.settings();
221+ const now = rt.now();
138222 const moves: SweepMove[] = [];
139223 const byWindow = new Map<number, TidyTab[]>();
140224 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -142,13 +226,15 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
142226 const ids = tabs.map((t) => t.id);
143227 const s = rt.state();
144228 for (const t of tabs) {
145− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
146− const rec = s.tabs[t.id];
147229 const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
148− if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
230+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
231+ const rec = s.tabs[t.id];
232+ if (rec) {
233+ rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234+ rec.parkedAt = now;
235+ }
149236 }
150− let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
151− if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
237+ let parking = await parkingGroupIn(rt, windowId);
152238 if (parking === undefined) {
153239 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
154240 } else {
@@ -162,12 +248,16 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
162248 rt.log("could not move parking group to the end", e);
163249 }
164250 if (settings.discardParked) {
251+ // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
165252 for (const t of tabs) {
166− if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
253+ if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
167254 }
168255 }
169256 }
170− rt.state().lastSweep = { at: rt.now(), moves };
257+ // Sweeps inside the undo window add up, so Undo puts back everything parked in that hour.
258+ const last = rt.state().lastSweep;
259+ const kept = last && now - last.at < UNDO_WINDOW_MS ? last.moves.filter((m) => !moves.some((n) => n.tabId === m.tabId)) : [];
260+ rt.state().lastSweep = { at: now, moves: [...kept, ...moves] };
171261 rt.commit();
172262 return moves.length;
173263}
@@ -197,18 +287,40 @@ export async function undoSweep(rt: Runtime): Promise<number> {
197287 const last = rt.state().lastSweep;
198288 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
199289 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
290+ // Parking every tab of a group removes it: undo makes it again, once, with its title and colour.
291+ const remade = new Map<number, number>();
292+ const target = async (m: SweepMove): Promise<number | undefined> => {
293+ if (m.groupId === -1) return undefined;
294+ const again = remade.get(m.groupId);
295+ if (again !== undefined) return again;
296+ return (await groupExists(m.groupId)) ? m.groupId : undefined;
297+ };
200298 let restored = 0;
201299 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
202300 const t = live.get(m.tabId);
203301 if (!t) continue;
204− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
205− else await ungroup(rt, [m.tabId]);
302+ let groupId = await target(m);
303+ if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
304+ else if (m.groupId !== -1 && m.group) {
305+ const g = m.group;
306+ groupId = await createManagedGroup(rt, [m.tabId], m.windowId, g.origin, { title: g.stripTitle ?? "", color: g.color }, { ...g });
307+ if (groupId !== undefined) remade.set(m.groupId, groupId);
308+ } else {
309+ rt.state().ownUngroups[m.tabId] = rt.now(); // back to loose is the worker's move, not the user pulling it out
310+ await ungroup(rt, [m.tabId]);
311+ }
206312 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
207313 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
208− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
314+ groupId = await target(m);
315+ if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
316+ else if ((await chrome.tabs.get(m.tabId).catch(() => undefined))?.groupId !== -1) {
317+ rt.state().ownUngroups[m.tabId] = rt.now();
318+ await ungroup(rt, [m.tabId]);
319+ }
209320 const rec = rt.state().tabs[m.tabId];
210321 if (rec) {
211322 rec.parkedFrom = undefined;
323+ rec.parkedAt = undefined;
212324 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
213325 }
214326 restored++;
test/engine.test.ts+126
@@ -240,6 +240,14 @@ describe("auto-organize triggers", () => {
240240 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
241241 });
242242
243+ it("parking keeps the title of the group the tab came from, for the archive", () => {
244+ const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 20 })], [{ id: 10, origin: "organize", title: "Rust async" }, { id: 20, origin: "tidy" }]);
245+ s.tabs[1].parkedFrom = "Rust async"; // set by the sweep just before it moves the tab
246+ const moved = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 20 }) }, ctx());
247+ const discarded = step(moved.state, { type: "tabUpdated", tab: tab(1, { groupId: 20, status: "unloaded" }) }, ctx());
248+ expect(discarded.state.tabs[1].parkedFrom).toBe("Rust async");
249+ });
250+
243251 it("opening a parked tab takes it out of Parked", () => {
244252 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
245253 s.tabs[1].parkedFrom = "Rust async";
@@ -310,3 +318,121 @@ describe("fit check", () => {
310318 expect(out.state.tabs[1].handPlaced).toBeUndefined();
311319 });
312320});
321+
322+describe("groups Chromium removes and brings back", () => {
323+ const opener = () => {
324+ const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "opener", title: "🦀 Rust async" }]);
325+ s.groups[10].color = "orange";
326+ return s;
327+ };
328+ const run = (s: ReturnType<typeof opener>, events: Parameters<typeof step>[1][], now = 1_000_000) =>
329+ events.reduce((acc, e) => step(acc, e, ctx(now)).state, s);
330+
331+ it("a group moved to another window stays Diagonal's, and its tabs are not taken for your choices", () => {
332+ const moved = run(opener(), [
333+ { type: "groupRemoved", groupId: 10 },
334+ { type: "tabUpdated", tab: tab(1) },
335+ { type: "tabUpdated", tab: tab(2) },
336+ { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
337+ { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
338+ { type: "tabUpdated", tab: tab(2, { groupId: 10, windowId: 2 }) },
339+ ]);
340+ expect(moved.groups[10]).toMatchObject({ origin: "opener", managed: true, userNamed: false, windowId: 2 });
341+ for (const id of [1, 2]) {
342+ expect(moved.tabs[id].groupId).toBe(10);
343+ expect(moved.tabs[id].keepLoose).toBeUndefined();
344+ expect(moved.tabs[id].handPlaced).toBeUndefined();
345+ }
346+ expect(moved.removedGroups[10]).toBeUndefined();
347+ });
348+
349+ it("the same when a tab's new group is reported before the group itself", () => {
350+ const moved = run(opener(), [
351+ { type: "groupRemoved", groupId: 10 },
352+ { type: "tabUpdated", tab: tab(1) },
353+ { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
354+ { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
355+ ]);
356+ expect(moved.groups[10].origin).toBe("opener");
357+ expect(moved.tabs[1].keepLoose).toBeUndefined();
358+ expect(moved.tabs[1].handPlaced).toBeUndefined();
359+ });
360+
361+ it("a closed group reopened untitled, then titled, is Diagonal's again", () => {
362+ const closed = run(opener(), [
363+ { type: "tabRemoved", tabId: 1 },
364+ { type: "tabRemoved", tabId: 2 },
365+ { type: "groupRemoved", groupId: 10 },
366+ ]);
367+ const later = 1_000_000 + 3_600_000;
368+ const back = run(closed, [
369+ { type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } },
370+ { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } },
371+ ], later);
372+ expect(back.groups[77]).toMatchObject({ origin: "opener", managed: true, userNamed: false, stripTitle: "🦀 Rust async" });
373+ });
374+
375+ it("a group you make and title yourself later is still yours", () => {
376+ const closed = run(opener(), [{ type: "groupRemoved", groupId: 10 }]);
377+ const made = run(closed, [{ type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } }]);
378+ const titled = step(made, { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } }, ctx(1_000_000 + 60_000)).state;
379+ expect(titled.groups[77]).toMatchObject({ origin: "user", userNamed: true });
380+ });
381+
382+ it("ungrouping by hand still keeps the tabs loose", () => {
383+ const out = run(opener(), [
384+ { type: "tabUpdated", tab: tab(1) },
385+ { type: "tabUpdated", tab: tab(2) },
386+ { type: "groupRemoved", groupId: 10 },
387+ ]);
388+ expect(out.tabs[1].keepLoose).toBe("https://example.com/1");
389+ });
390+
391+ it("keeps a day of removed groups, at most 20", () => {
392+ let s = opener();
393+ for (let id = 100; id < 125; id++) {
394+ s.groups[id] = { ...s.groups[10], id };
395+ s = step(s, { type: "groupRemoved", groupId: id }, ctx(1_000_000 + id)).state;
396+ }
397+ expect(Object.keys(s.removedGroups)).toHaveLength(20);
398+ s = step(s, { type: "tabActivated", tabId: 1 }, ctx(1_000_000 + 25 * 3_600_000)).state;
399+ expect(Object.keys(s.removedGroups)).toHaveLength(0);
400+ });
401+});
402+
403+describe("tabs you place", () => {
404+ it("a new tab made inside a group ('New tab in group') is yours there", () => {
405+ const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
406+ const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, url: "brave://newtab/" }) }, ctx());
407+ expect(state.tabs[3].handPlaced).toBe(true);
408+ });
409+
410+ it("a link opened from a grouped tab is not", () => {
411+ const opener = tab(1, { groupId: 10 });
412+ const s = stateWith([opener, tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
413+ const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, openerTabId: 1 }), opener }, ctx());
414+ expect(state.tabs[3].handPlaced).toBeUndefined();
415+ });
416+
417+ it("a tab that joins a group has no loose choice left, even if a late event said it left", () => {
418+ const s = stateWith([tab(1), tab(2)], [{ id: 10, origin: "opener" }]);
419+ s.tabs[1].keepLoose = "https://example.com/1";
420+ s.ownAdds[1] = 1_000_000;
421+ const { state } = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
422+ expect(state.tabs[1].keepLoose).toBeUndefined();
423+ });
424+
425+ it("a new tab whose first load redirects is not fit-checked", () => {
426+ const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
427+ const opened = step(s, { type: "tabCreated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "", pendingUrl: "http://blog.example.org/post", status: "loading" }), opener: tab(1, { groupId: 10 }) }, ctx()).state;
428+ const landed = step(opened, { type: "tabUpdated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "https://blog.example.org/post" }) }, ctx());
429+ expect(landed.state.tabs[4].fitPending).toBeUndefined();
430+ });
431+
432+ it("leaving Parked stops the archive clock", () => {
433+ const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
434+ s.tabs[1].parkedAt = 5;
435+ expect(step(s, { type: "tabUpdated", tab: tab(1) }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
436+ expect(step(s, { type: "tabActivated", tabId: 1 }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
437+ });
438+});
test/sweep.test.tsnew file+181
@@ -0,0 +1,181 @@
1+import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+import type { Runtime } from "../src/background/runtime";
3+import { emptyState, newGroupRecord, type State } from "../src/background/state";
4+import { HOUR, runSweep, undoSweep } from "../src/background/tidy";
5+import { settings as makeSettings } from "./helpers";
6+
7+/** The tidy sweep against a small in-memory strip with Chromium's group rules: an emptied group is removed. */
8+
9+type LiveTab = {
10+ id: number; windowId: number; index: number; groupId: number; url: string; title: string;
11+ pinned: boolean; active: boolean; discarded: boolean; autoDiscardable: boolean; lastAccessed: number;
12+};
13+type LiveGroup = { id: number; windowId: number; title: string; color: string; collapsed: boolean };
14+
15+const T0 = 10_000 * HOUR;
16+let now: number;
17+let live: LiveTab[];
18+let groups: Map<number, LiveGroup>;
19+let nextGroup: number;
20+let state: State;
21+let discarded: number[];
22+
23+const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
24+ id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/p`, title: `Tab ${id}`,
25+ pinned: false, active: false, discarded: false, autoDiscardable: true, lastAccessed: T0 - 60 * HOUR, ...over,
26+});
27+
28+function remember(t: LiveTab): void {
29+ 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, createdAt: 0, lastActivatedAt: t.lastAccessed };
30+}
31+
32+const dropEmpty = () => {
33+ for (const id of [...groups.keys()]) if (!live.some((t) => t.groupId === id)) groups.delete(id);
34+};
35+
36+const rt: Runtime = {
37+ state: () => state,
38+ settings: () => makeSettings(),
39+ commit: () => undefined,
40+ now: () => now,
41+ host: async () => ({ ok: false, error: { code: "FM_ERROR", message: "not used" } }) as never,
42+ naming: { touch: () => undefined } as unknown as Runtime["naming"],
43+ refreshBadge: () => undefined,
44+ log: () => undefined,
45+};
46+
47+beforeEach(() => {
48+ now = T0;
49+ state = emptyState();
50+ groups = new Map();
51+ nextGroup = 500;
52+ discarded = [];
53+ (globalThis as any).chrome = {
54+ tabs: {
55+ query: async (q: { windowId?: number; groupId?: number }) =>
56+ live.filter((t) => (q.windowId === undefined || t.windowId === q.windowId) && (q.groupId === undefined || t.groupId === q.groupId)).map((t) => ({ ...t })),
57+ get: async (id: number) => ({ ...live.find((t) => t.id === id)! }),
58+ group: async ({ tabIds, groupId, createProperties }: { tabIds: number[]; groupId?: number; createProperties?: { windowId: number } }) => {
59+ let id = groupId;
60+ if (id === undefined) {
61+ id = nextGroup++;
62+ groups.set(id, { id, windowId: createProperties?.windowId ?? 1, title: "", color: "grey", collapsed: false });
63+ }
64+ for (const t of live) if (tabIds.includes(t.id)) t.groupId = id;
65+ dropEmpty();
66+ return id;
67+ },
68+ ungroup: async (ids: number[]) => {
69+ for (const t of live) if (ids.includes(t.id)) t.groupId = -1;
70+ dropEmpty();
71+ },
72+ move: async () => undefined,
73+ discard: async (id: number) => {
74+ discarded.push(id);
75+ live.find((t) => t.id === id)!.discarded = true;
76+ },
77+ remove: async (ids: number[]) => {
78+ live = live.filter((t) => !ids.includes(t.id));
79+ dropEmpty();
80+ },
81+ },
82+ tabGroups: {
83+ get: async (id: number) => {
84+ const g = groups.get(id);
85+ if (!g) throw new Error(`No group with id: ${id}.`);
86+ return { ...g };
87+ },
88+ query: async (q: { windowId?: number }) => [...groups.values()].filter((g) => q.windowId === undefined || g.windowId === q.windowId).map((g) => ({ ...g })),
89+ update: async (id: number, props: Partial<LiveGroup>) => Object.assign(groups.get(id)!, props),
90+ move: async () => undefined,
91+ },
92+ };
93+});
94+
95+afterEach(() => {
96+ delete (globalThis as any).chrome;
97+});
98+
99+function strip(tabs: LiveTab[], made: LiveGroup[] = []): void {
100+ live = tabs;
101+ for (const g of made) groups.set(g.id, g);
102+ for (const t of tabs) remember(t);
103+}
104+
105+const parkedGroupId = () => Object.values(state.groups).find((g) => g.origin === "tidy")?.id;
106+
107+describe("tidy sweep", () => {
108+ it("parks a stale tab and closes it only after it has sat in Parked for the archive time", async () => {
109+ strip([liveTab(1), liveTab(2, { active: true })]);
110+ expect(await runSweep(rt)).toMatchObject({ parked: 1, archived: 0 });
111+ expect(live.find((t) => t.id === 1)!.groupId).toBe(parkedGroupId());
112+ expect(state.tabs[1].parkedAt).toBe(T0);
113+
114+ now = T0 + 47 * HOUR;
115+ expect(await runSweep(rt)).toMatchObject({ archived: 0 });
116+ now = T0 + 48 * HOUR;
117+ expect(await runSweep(rt)).toMatchObject({ archived: 1 });
118+ expect(state.archive.map((a) => a.url)).toEqual(["https://site1.com/p"]);
119+ });
120+
121+ it("two sweeps at once archive a tab once", async () => {
122+ strip([liveTab(1, { groupId: 9 }), liveTab(2, { active: true })], [{ id: 9, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
123+ state.groups[9] = newGroupRecord(9, 1, "tidy", "grey", { userNamed: true, stripTitle: "💤 Parked" });
124+ state.tabs[1].parkedAt = T0 - 49 * HOUR;
125+ await Promise.all([runSweep(rt), runSweep(rt, true)]);
126+ expect(state.archive).toHaveLength(1);
127+ });
128+
129+ it("a Parked group whose record an older version lost is Parked again", async () => {
130+ strip([liveTab(1, { groupId: 50 }), liveTab(2)], [{ id: 50, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
131+ state.groups[50] = newGroupRecord(50, 1, "user", "grey", { userNamed: true, stripTitle: "💤 Parked" });
132+ await runSweep(rt);
133+ expect(live.find((t) => t.id === 2)!.groupId).toBe(50);
134+ expect(groups.size).toBe(1);
135+ expect(state.groups[50]).toMatchObject({ origin: "tidy", managed: true });
136+ });
137+
138+ it("does not discard a tab marked never to be discarded", async () => {
139+ strip([liveTab(1), liveTab(2, { autoDiscardable: false }), liveTab(3, { active: true })]);
140+ await runSweep(rt);
141+ expect(discarded).toEqual([1]);
142+ });
143+});
144+
145+describe("undo tidy", () => {
146+ it("brings back a group that parking emptied, with its title", async () => {
147+ strip(
148+ [liveTab(1, { active: true }), liveTab(2, { groupId: 7 }), liveTab(3, { groupId: 7 })],
149+ [{ id: 7, windowId: 1, title: "🦀 Rust async", color: "orange", collapsed: false }],
150+ );
151+ state.groups[7] = newGroupRecord(7, 1, "organize", "orange", { title: "Rust async", emoji: "🦀", stripTitle: "🦀 Rust async" });
152+ await runSweep(rt);
153+ expect(groups.has(7)).toBe(false);
154+
155+ expect(await undoSweep(rt)).toBe(2);
156+ const back = live.find((t) => t.id === 2)!.groupId;
157+ expect(back).not.toBe(-1);
158+ expect(live.find((t) => t.id === 3)!.groupId).toBe(back);
159+ expect(groups.get(back)).toMatchObject({ title: "🦀 Rust async", color: "orange" });
160+ expect(state.groups[back]).toMatchObject({ origin: "organize", title: "Rust async", userNamed: false });
161+ expect(state.tabs[2].parkedAt).toBeUndefined();
162+ });
163+
164+ it("puts loose tabs back as the worker's own move, so they are not taken for tabs you pulled out", async () => {
165+ strip([liveTab(1, { active: true }), liveTab(4)]);
166+ await runSweep(rt);
167+ await undoSweep(rt);
168+ expect(live.find((t) => t.id === 4)!.groupId).toBe(-1);
169+ expect(state.ownUngroups[4]).toBeDefined();
170+ });
171+
172+ it("covers every sweep in the last hour, not only the latest", async () => {
173+ strip([liveTab(1, { active: true }), liveTab(2), liveTab(3, { lastAccessed: T0 - 23.5 * HOUR })]);
174+ state.tabs[3].lastActivatedAt = T0 - 23.5 * HOUR;
175+ await runSweep(rt, true);
176+ now = T0 + HOUR / 2;
177+ await runSweep(rt);
178+ expect(live.filter((t) => t.groupId !== -1).map((t) => t.id)).toEqual([2, 3]);
179+ expect(await undoSweep(rt)).toBe(2);
180+ });
181+});
test/tidy.test.ts+34−3
@@ -34,6 +34,19 @@ describe("park eligibility", () => {
3434 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
3535 });
3636
37+ it("a Diagonal group you renamed counts as yours", () => {
38+ const s2 = emptyState();
39+ s2.groups[14] = newGroupRecord(14, 1, "organize", "red", { userNamed: true, stripTitle: "Tax 2026" });
40+ expect(isParkCandidate(tt({ groupId: 14 }), s2, cfg, NOW)).toBe(false);
41+ expect(isParkCandidate(tt({ groupId: 14 }), s2, settings({ tidyUserGroups: true }), NOW)).toBe(true);
42+ });
43+
44+ it("after a restart, the stored last use wins over Chromium's restore time", () => {
45+ const s2 = emptyState();
46+ s2.tabs[1] = { id: 1, windowId: 1, groupId: -1, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 30 * HOUR };
47+ expect(isParkCandidate(tt({ lastAccessed: NOW - 60_000 }), s2, cfg, NOW)).toBe(true);
48+ });
49+
3750 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
3851 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
3952 });
@@ -51,14 +64,32 @@ describe("park eligibility", () => {
5164});
5265
5366describe("archive", () => {
54− it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
67+ const record = (id: number, over: object = {}) => ({ id, windowId: 1, groupId: 12, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 60 * HOUR, ...over });
68+
69+ it("parked tabs that sat in Parked past archiveAfter are archived; 0 means never", () => {
5570 const s = emptyState();
5671 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
57− const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
58− expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
72+ s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
73+ const parked = tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR });
74+ expect(archiveCandidates([parked, tt({ id: 2 })], s, settings(), NOW)).toEqual([parked]);
5975 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
6076 });
6177
78+ it("counts from when the tab was parked, not from when it was last used", () => {
79+ const s = emptyState();
80+ s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
81+ s.tabs[1] = record(1, { parkedAt: NOW - HOUR }); // unused for 60 h, but parked only an hour ago
82+ s.tabs[2] = record(2); // in Parked with no parking time: its clock has not started
83+ expect(archiveCandidates([tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR }), tt({ id: 2, groupId: 12 })], s, settings(), NOW)).toEqual([]);
84+ });
85+
86+ it("an address added to Never tidy is not closed from Parked", () => {
87+ const s = emptyState();
88+ s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
89+ s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
90+ expect(archiveCandidates([tt({ groupId: 12, url: "https://jira.corp/x" })], s, settings({ tidyExclusions: ["jira."] }), NOW)).toEqual([]);
91+ });
92+
6293 it("is newest first and capped, oldest dropped", () => {
6394 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
6495 const existing = [3, 2, 1].map(entry);