@@ -1,10 +1,11 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
44import { pathKey } from "./engine";
55import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
66import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7−import { markDirty, type State } from "./state";
7+import type { HostReply } from "./host";
8+import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
89
910/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1011
@@ -26,6 +27,8 @@ export interface ExistingGroup {
2627export interface OrganizePayload {
2728 items: NameItem[];
2829 existingGroups: ExistingGroup[];
30+ /** Every group label in the window: a new group the host names must not repeat one. */
31+ siblingTitles?: string[];
2932 allowNew: boolean;
3033 maxGroups: number;
3134 minGroupSize: number;
@@ -151,88 +154,198 @@ export interface OrganizeReport {
151154 error?: string;
152155}
153156
154−/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
157+/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
155158export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
159+ const members = new Map<number, TabRecord[]>();
160+ for (const t of Object.values(state.tabs)) if (t.groupId !== -1) members.set(t.groupId, [...(members.get(t.groupId) ?? []), t]);
161+ const inUse = (g: GroupRecord): number =>
162+ Math.max(g.dirtyAt ?? 0, g.lastNamedAt ?? 0, ...(members.get(g.id) ?? []).map((t) => t.lastActivatedAt ?? 0));
156163 const groups = Object.values(state.groups)
157− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
158− .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
159− .slice(0, EXISTING_GROUPS_CAP);
164+ .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && !g.restored && (g.stripTitle || g.title))
165+ .map((g) => ({ g, first: extraIds.includes(g.id), at: inUse(g) }))
166+ .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
167+ .slice(0, EXISTING_GROUPS_CAP)
168+ .map(({ g }) => g);
160169 const list = groups.map((g, i) => ({
161170 g: i,
162171 title: labelOf(g.stripTitle || g.title),
163− samples: Object.values(state.tabs)
164− .filter((t) => t.groupId === g.id)
165− .slice(0, 2)
166− .map((t) => t.title.slice(0, 60)),
172+ samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
167173 }));
168174 return { ids: groups.map((g) => g.id), list };
169175}
170176
171−export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
177+/** Errors about what was sent rather than about the host: a smaller or plainer request may go through. */
178+const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_MODEL_OUTPUT"]);
179+const MAX_TIMEOUT_SPLITS = 1;
180+
181+/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
182+async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
183+ const settings = rt.settings();
184+ const s = rt.state();
185+ const payload: OrganizePayload = {
186+ items: toItems(part, settings, withDescriptions),
187+ existingGroups,
188+ siblingTitles: Object.values(s.groups)
189+ .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190+ .map((g) => labelOf(g.stripTitle || g.title))
191+ .filter(Boolean)
192+ .slice(0, 20),
193+ allowNew: part.length >= 2,
194+ maxGroups: maxGroupsFor(part.length),
195+ minGroupSize: settings.organizeMinGroupSize,
196+ };
197+ let reply = await rt.host<OrganizeResult>("organize", payload);
198+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
199+ if (!reply.ok && reply.error.code === "GUARDRAIL" && payload.items.some((i) => i.description)) {
200+ // What a page says is the likeliest thing to trip Apple's filter; its title and address rarely do.
201+ payload.items = toItems(part, settings, false);
202+ reply = await rt.host<OrganizeResult>("organize", payload);
203+ }
204+ return reply;
205+}
206+
207+/** One organize run over a window: what it made, what it moved, and whether the host stopped it. */
208+export interface SortRun {
209+ windowId: number;
210+ createdIds: number[];
211+ previous: Record<number, number>;
212+ grouped: number;
213+ left: number;
214+ timeoutSplits: number;
215+ /** Auto-organize: remember what was looked at, so the same tabs are not sent again unchanged. */
216+ stamp: boolean;
217+ stopped?: HostError;
218+}
219+
220+export const newRun = (windowId: number, stamp: boolean): SortRun => ({ windowId, createdIds: [], previous: {}, grouped: 0, left: 0, timeoutSplits: 0, stamp });
221+
222+/**
223+ * Sort one part of a window. A reply about the tabs themselves (too long, refused by Apple's filter, not
224+ * valid output) splits the part in halves, so one page the model won't handle leaves the others sorted; a
225+ * tab that fails alone is set aside until its page or title changes. Tabs that changed while the model was
226+ * thinking are left out of its answer.
227+ */
228+export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe = false): Promise<void> {
229+ if (!part.length || run.stopped) return;
230+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), run.windowId, run.createdIds);
231+ if (part.length < 2 && !existingIds.length && !probe) {
232+ run.left += part.length;
233+ return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
234+ }
235+ const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
236+ if (!reply.ok) {
237+ const e = reply.error;
238+ const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
239+ if (part.length > 1 && (CONTENT_ERRORS.has(e.code) || timedOut)) {
240+ if (timedOut) run.timeoutSplits++;
241+ const half = Math.ceil(part.length / 2);
242+ await sortPart(rt, part.slice(0, half), run, true);
243+ await sortPart(rt, part.slice(half), run, true);
244+ return;
245+ }
246+ if (CONTENT_ERRORS.has(e.code)) {
247+ const rec = rt.state().tabs[part[0].id];
248+ if (rec) rec.organizeRefused = organizedKeyOf(part[0]);
249+ rt.log("organize: set aside a tab the model would not sort", e.code);
250+ run.left += 1;
251+ return;
252+ }
253+ run.stopped = e;
254+ return;
255+ }
172256 const settings = rt.settings();
257+ const plan = planFromResult(reply.result, part.map((m) => m.id), existingIds, settings.organizeMinGroupSize, maxGroupsFor(part.length), (ids) =>
258+ colorFor(part.find((m) => m.id === ids[0])?.url ?? ""),
259+ );
260+ const unchanged = await stillAsSent(rt, part, run.windowId);
261+ const trimmed = trimPlan(rt.state(), plan, unchanged, settings.organizeMinGroupSize);
262+ const failed = await applyPlan(rt, trimmed, run.windowId, part, run.previous, run.createdIds);
263+ const placed = [...trimmed.joins, ...trimmed.creates].reduce((n, x) => n + x.tabIds.filter((id) => !failed.has(id)).length, 0);
264+ run.grouped += placed;
265+ run.left += part.length - placed;
266+ if (run.stamp) {
267+ const s = rt.state();
268+ for (const m of part) {
269+ const rec = s.tabs[m.id];
270+ if (rec && unchanged.has(m.id) && !failed.has(m.id)) rec.organizedKey = organizedKeyOf(m);
271+ }
272+ }
273+}
274+
275+/** The tabs of a part that are still loose, in this window, on the page and title the model saw. */
276+async function stillAsSent(rt: Runtime, part: Member[], windowId: number): Promise<Set<number>> {
277+ const live = new Map((await chrome.tabs.query({ windowId }).catch(() => [])).map((t) => [t.id!, t]));
278+ const s = rt.state();
279+ return new Set(
280+ part
281+ .filter((m) => {
282+ const t = live.get(m.id);
283+ return !!t && organizable(t) && !s.tabs[m.id]?.keepLoose && organizedKeyOf(t) === organizedKeyOf(m);
284+ })
285+ .map((m) => m.id),
286+ );
287+}
288+
289+/** A plan with the tabs that moved on taken out: a new group left too small is not made, a join into a group gone is dropped. */
290+export function trimPlan(state: State, plan: Plan, keep: Set<number>, minGroupSize: number): Plan {
291+ const out: Plan = { joins: [], creates: [], leftovers: plan.leftovers.filter((id) => keep.has(id)) };
292+ for (const j of plan.joins) {
293+ const tabIds = j.tabIds.filter((id) => keep.has(id));
294+ const g = state.groups[j.groupId];
295+ if (tabIds.length && g?.managed && !g.restored) out.joins.push({ ...j, tabIds });
296+ else out.leftovers.push(...tabIds);
297+ }
298+ for (const c of plan.creates) {
299+ const tabIds = c.tabIds.filter((id) => keep.has(id));
300+ if (tabIds.length >= Math.max(2, minGroupSize)) out.creates.push({ ...c, tabIds });
301+ else out.leftovers.push(...tabIds);
302+ }
303+ return out;
304+}
305+
306+export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307+ const s0 = rt.state();
173308 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
174309 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
175− if (tabs.length < 2) {
310+ // A tab the model already would not sort, on the same page, is not sent again.
311+ const sendable = tabs.filter((t) => s0.tabs[t.id!]?.organizeRefused !== organizedKeyOf(t));
312+ if (sendable.length < 2) {
176313 report.left = tabs.length;
177314 return report;
178315 }
179− const members: Member[] = tabs.map((t) => ({
180− id: t.id!,
181− index: t.index,
182− url: t.url ?? "",
183− title: t.title ?? "",
184− status: t.status,
185− description: rt.state().tabs[t.id!]?.description,
186− lastActive: t.lastAccessed ?? 0,
187− }));
188− const previous: Record<number, number> = {};
189− const createdIds: number[] = [];
190− const queue = makeBatches(members);
316+ const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
191317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
192318 rt.refreshBadge();
193319 try {
194− while (queue.length) {
195− const batch = queue.shift()!;
196− const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), windowId, createdIds);
197− const maxGroups = maxGroupsFor(batch.length);
198− const payload: OrganizePayload = {
199− items: toItems(batch, settings),
200− existingGroups,
201− allowNew: true,
202− maxGroups,
203− minGroupSize: settings.organizeMinGroupSize,
204− };
205− let reply = await rt.host<OrganizeResult>("organize", payload);
206− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
207− const half = Math.ceil(batch.length / 2);
208− queue.unshift(batch.slice(0, half), batch.slice(half));
209− continue;
210− }
211− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
212− if (!reply.ok) {
213− report.error = `${reply.error.code}: ${reply.error.message}`;
214− report.left += batch.length + queue.flat().length;
215− break;
216− }
217− const batchIds = batch.map((m) => m.id);
218− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, maxGroups, (ids) =>
219− colorFor(members.find((m) => m.id === ids[0])?.url ?? ""),
220− );
221− await applyPlan(rt, plan, windowId, members, previous, createdIds);
222− report.grouped += plan.joins.reduce((n, j) => n + j.tabIds.length, 0) + plan.creates.reduce((n, c) => n + c.tabIds.length, 0);
223− report.left += plan.leftovers.length;
320+ for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
321+ await sortPart(rt, batch, run);
322+ if (run.stopped) break;
224323 }
225324 } finally {
226325 const s = rt.state();
227326 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
228− report.groups = createdIds.length;
229− if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
327+ report.grouped = run.grouped;
328+ report.groups = run.createdIds.length;
329+ report.left = tabs.length - run.grouped;
330+ if (run.stopped) report.error = `${run.stopped.code}: ${run.stopped.message}`;
331+ if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
230332 rt.commit();
231333 rt.refreshBadge();
232334 }
233335 return report;
234336}
235337
338+const toMember = (s: State, t: chrome.tabs.Tab): Member => ({
339+ id: t.id!,
340+ index: t.index,
341+ url: t.url ?? "",
342+ title: t.title ?? "",
343+ status: t.status,
344+ description: s.tabs[t.id!]?.description,
345+ lastActive: t.lastAccessed ?? 0,
346+});
347+
348+/** Carry out a plan; returns the tabs whose move failed, which stay as they were. */
236349export async function applyPlan(
237350 rt: Runtime,
238351 plan: Plan,
@@ -240,11 +353,15 @@ export async function applyPlan(
240353 members: Member[],
241354 previous: Record<number, number>,
242355 createdIds: number[],
243−): Promise<void> {
356+): Promise<Set<number>> {
244357 const settings = rt.settings();
245358 const byId = new Map(members.map((m) => [m.id, m]));
359+ const failed = new Set<number>();
246360 for (const j of plan.joins) {
247− if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
361+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
362+ for (const id of j.tabIds) failed.add(id);
363+ continue;
364+ }
248365 for (const id of j.tabIds) previous[id] = -1;
249366 const g = rt.state().groups[j.groupId];
250367 if (g) {
@@ -265,7 +382,10 @@ export async function applyPlan(
265382 lastNamedAt: named ? rt.now() : undefined,
266383 dirty: false,
267384 });
268− if (groupId === undefined) continue;
385+ if (groupId === undefined) {
386+ for (const id of c.tabIds) failed.add(id);
387+ continue;
388+ }
269389 createdIds.push(groupId);
270390 for (const id of c.tabIds) previous[id] = -1;
271391 const g = rt.state().groups[groupId];
@@ -274,6 +394,7 @@ export async function applyPlan(
274394 rt.naming.touch(groupId);
275395 }
276396 }
397+ return failed;
277398}
278399
279400/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -301,7 +422,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
301422}
302423
303424/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
304−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
425+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
305426
306427export const AUTO_RETRY_MS = 300_000;
307428export const AUTO_CONTINUE_MS = 1_500;
@@ -379,59 +500,25 @@ export class AutoOrganizer {
379500 const s = rt.state();
380501 // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
381502 // their title and address, so they're sorted like loaded ones.
382− const loose = tabs.filter((t) => organizable(t) && settled(t) && !s.tabs[t.id!]?.keepLoose);
503+ const loose = tabs.filter((t) => {
504+ const rec = s.tabs[t.id!];
505+ return organizable(t) && settled(t) && !rec?.keepLoose && rec?.organizeRefused !== organizedKeyOf(t);
506+ });
383507 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
384508 if (!fresh.length) return;
385− const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
386− if (loose.length < 2 && !existingIds.length) return; // one loose tab and nothing to join: wait for company
387− const toMember = (t: chrome.tabs.Tab): Member => ({
388− id: t.id!,
389− index: t.index,
390− url: t.url ?? "",
391− title: t.title ?? "",
392− status: t.status,
393− description: s.tabs[t.id!]?.description,
394− lastActive: t.lastAccessed ?? 0,
395− });
396− const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map(toMember);
509+ if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.length) return; // one loose tab and nothing to join: wait for company
510+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
397511 const batch = makeBatches(ordered)[0];
398− const maxGroups = maxGroupsFor(batch.length);
399− const payload: OrganizePayload = {
400− items: toItems(batch, settings),
401− existingGroups,
402− allowNew: batch.length >= 2,
403− maxGroups,
404− minGroupSize: settings.organizeMinGroupSize,
405− };
406− let reply = await rt.host<OrganizeResult>("organize", payload);
407− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
408− // Rare with the budgeted batch; try once with half, the rest follows on the next run.
409− const half = batch.slice(0, Math.ceil(batch.length / 2));
410− payload.items = toItems(half, settings);
411− payload.maxGroups = maxGroupsFor(half.length);
412− payload.allowNew = half.length >= 2;
413− batch.length = half.length;
414− reply = await rt.host<OrganizeResult>("organize", payload);
415− }
416− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
417− if (!reply.ok) {
418− rt.log("auto-organize", reply.error.code);
512+ const run = newRun(windowId, true);
513+ await sortPart(rt, batch, run);
514+ rt.commit();
515+ if (run.stopped) {
516+ rt.log("auto-organize", run.stopped.code);
419517 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
420518 return;
421519 }
422− const batchIds = batch.map((m) => m.id);
423− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, payload.maxGroups, (ids) =>
424− colorFor(batch.find((m) => m.id === ids[0])?.url ?? ""),
425− );
426− await applyPlan(rt, plan, windowId, batch, {}, []);
427− const live = rt.state();
428− for (const m of batch) {
429− const rec = live.tabs[m.id];
430− if (rec) rec.organizedKey = organizedKeyOf(m);
431− }
432− rt.commit();
433− const remaining = fresh.filter((t) => !batchIds.includes(t.id!));
434− if (remaining.length) this.touch(windowId, AUTO_CONTINUE_MS);
520+ const sent = new Set(batch.map((m) => m.id));
521+ if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
435522 }
436523}
437524