@@ -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;
18// ----- state cache ------------------------------------------------------------------------------
19// ----- state cache ------------------------------------------------------------------------------
@@ -65,11 +66,21 @@ function logError(where: string, e: unknown): void {
66// ----- host ------------------------------------------------------------------------------------
67// ----- host ------------------------------------------------------------------------------------
69+let modelQueue: Promise<unknown> = Promise.resolve();
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);
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);
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();
127 commit();
139 commit();
@@ -213,6 +225,15 @@ const rt: Runtime = {
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 });
235+const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
216// ----- engine glue -----------------------------------------------------------------------------
237// ----- engine glue -----------------------------------------------------------------------------
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);
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);
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 });
310+ autoOrganizer.touch(a.windowId);
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,
@@ -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();
@@ -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();
@@ -453,13 +492,6 @@ async function rewriteTitles(): Promise<void> {
456−// ----- commands --------------------------------------------------------------------------------
458−async function activeTab(): Promise<chrome.tabs.Tab | undefined> {
459− const [t] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
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);
473−chrome.commands.onCommand.addListener((command) => {
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);
487− logError(`command ${command}`, e);
492// ----- messages (content script, popup, options) -----------------------------------------------
505// ----- messages (content script, popup, options) -----------------------------------------------
494type Msg = { kind?: string; cmd?: string; [k: string]: any };
507type Msg = { kind?: string; cmd?: string; [k: string]: any };