@@ -2,7 +2,7 @@ import { stripTitle } from "../shared/label";
22import { applyEvent, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
33import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
44import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5−import { organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5+import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
66import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
77import { withDefaults, type Settings } from "./settings";
88import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
@@ -13,6 +13,7 @@ import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, und
1313const ALARM_NAMING = "naming-fallback";
1414const ALARM_TIDY = "tidy-sweep";
1515const ALARM_HOST = "host-retry";
16+const ALARM_AUTO = "auto-organize";
1617const HOST_RETRY_MS = 600_000;
1718
1819// ----- state cache ------------------------------------------------------------------------------
@@ -65,11 +66,21 @@ function logError(where: string, e: unknown): void {
6566
6667// ----- host ------------------------------------------------------------------------------------
6768
69+let modelQueue: Promise<unknown> = Promise.resolve();
70+
71+/** Naming and organizing both run the model: one call at a time, so they never compete for it. */
72+function oneModelCall<T>(fn: () => Promise<T>): Promise<T> {
73+ const next = modelQueue.then(fn, fn);
74+ modelQueue = next.catch(() => undefined);
75+ return next;
76+}
77+
6878async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}): Promise<HostReply<T>> {
6979 const opts: HostOpts = { model: settings.model, timeoutMs: settings.timeoutMs, emoji: settings.emoji, debug: settings.debugLog, ...extra };
7080 busy(true);
7181 try {
72− const reply = await callHost<T>(chromeSender, op, payload, opts);
82+ const call = () => callHost<T>(chromeSender, op, payload, opts);
83+ const reply = op === "ping" ? await call() : await oneModelCall(call);
7384 const h = state.host;
7485 if (reply.ok) {
7586 h.lastOkAt = Date.now();
@@ -122,6 +133,7 @@ async function ping(): Promise<HostReply<PingResult>> {
122133 h.lastError = undefined;
123134 chrome.alarms.clear(ALARM_HOST);
124135 void naming.sweepDirty();
136+ void autoOrganizer.sweep();
125137 }
126138 }
127139 commit();
@@ -213,6 +225,15 @@ const rt: Runtime = {
213225 log,
214226};
215227
228+async function scheduleAutoFallback(when: number): Promise<void> {
229+ const at = Math.max(when, Date.now() + 30_000);
230+ const existing = await chrome.alarms.get(ALARM_AUTO);
231+ if (existing && existing.scheduledTime <= at && existing.scheduledTime > Date.now()) return;
232+ await chrome.alarms.create(ALARM_AUTO, { when: at });
233+}
234+
235+const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
236+
216237// ----- engine glue -----------------------------------------------------------------------------
217238
218239let chain: Promise<unknown> = Promise.resolve();
@@ -274,8 +295,20 @@ async function run(a: Action): Promise<void> {
274295 await addToGroup(rt, a.tabIds, a.groupId);
275296 break;
276297 case "ungroup":
298+ for (const id of a.tabIds) state.ownUngroups[id] = Date.now();
277299 await ungroup(rt, a.tabIds);
278300 break;
301+ case "unpark": {
302+ const rec = state.tabs[a.tabId];
303+ if (rec) rec.organizedKey = undefined;
304+ state.ownUngroups[a.tabId] = Date.now();
305+ await ungroup(rt, [a.tabId]);
306+ if ((await chrome.tabs.query({ groupId: a.groupId }).catch(() => [])).length) await updateGroup(rt, a.groupId, { collapsed: true });
307+ break;
308+ }
309+ case "loose":
310+ autoOrganizer.touch(a.windowId);
311+ break;
279312 case "scheduleDissolve":
280313 // Re-checked after the delay so Cmd+Shift+T right after a close does not flicker.
281314 setTimeout(() => {
@@ -324,6 +357,8 @@ async function reconcile(): Promise<void> {
324357 openerTabId: t.openerTabId,
325358 description: prev?.description,
326359 parkedFrom: prev?.parkedFrom,
360+ keepLoose: prev?.keepLoose,
361+ organizedKey: prev?.organizedKey,
327362 createdAt: prev?.createdAt ?? now,
328363 lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
329364 };
@@ -351,6 +386,7 @@ async function reconcile(): Promise<void> {
351386 state.groups = nextGroups;
352387 state.pendingCreates = [];
353388 state.ownWrites = {};
389+ state.ownUngroups = {};
354390 if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
355391 if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
356392 commit();
@@ -373,6 +409,7 @@ async function boot(reason: "startup" | "installed"): Promise<void> {
373409 await ping();
374410 await naming.sweepDirty();
375411 if (reason === "startup") void runSweep(rt).catch((e) => logError("tidy", e));
412+ await autoOrganizer.sweep();
376413 refreshBadge();
377414}
378415
@@ -425,6 +462,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
425462 void (async () => {
426463 await ready;
427464 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
465+ else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
428466 else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
429467 else if (alarm.name === ALARM_HOST) {
430468 const r = await ping();
@@ -441,6 +479,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
441479 settings = withDefaults(changes.settings.newValue);
442480 if (before.emoji !== settings.emoji) await rewriteTitles();
443481 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
482+ if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
444483 refreshBadge();
445484 })();
446485});
@@ -453,13 +492,6 @@ async function rewriteTitles(): Promise<void> {
453492 }
454493}
455494
456−// ----- commands --------------------------------------------------------------------------------
457−
458−async function activeTab(): Promise<chrome.tabs.Tab | undefined> {
459− const [t] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
460− return t;
461−}
462−
463495async function nameGroupNow(groupId: number): Promise<void> {
464496 const g = state.groups[groupId];
465497 if (!g) return;
@@ -470,25 +502,6 @@ async function nameGroupNow(groupId: number): Promise<void> {
470502 await naming.nameNow(groupId);
471503}
472504
473−chrome.commands.onCommand.addListener((command) => {
474− void (async () => {
475− await ready;
476− try {
477− if (command === "organize-window") {
478− const t = await activeTab();
479− if (t) await organizeWindow(rt, t.windowId);
480− } else if (command === "rename-current-group") {
481− const t = await activeTab();
482− if (t && (t.groupId ?? -1) !== -1) await nameGroupNow(t.groupId);
483− } else if (command === "tidy-now") {
484− await runSweep(rt, true);
485− }
486− } catch (e) {
487− logError(`command ${command}`, e);
488− }
489− })();
490−});
491−
492505// ----- messages (content script, popup, options) -----------------------------------------------
493506
494507type Msg = { kind?: string; cmd?: string; [k: string]: any };