on master help sign in
Stop one hard-to-name group from pausing Diagonal, and accept mixed-script titles
9dba9d5Claude
host/tests/golden/labels.jsonnew file+102
@@ -0,0 +1,102 @@
1+[
2+ [
3+ "\"Rust async runtimes.\"",
4+ "Rust async runtimes"
5+ ],
6+ [
7+ "rust async runtimes",
8+ "Rust async runtimes"
9+ ],
10+ [
11+ "Rust tabs and docs",
12+ "Rust and docs"
13+ ],
14+ [
15+ "One two three four five six",
16+ "One two three four"
17+ ],
18+ [
19+ "Machine learning research papers reading list",
20+ "Machine learning research"
21+ ],
22+ [
23+ "iPhone camera tips",
24+ "iPhone camera tips"
25+ ],
26+ [
27+ "macOS updates",
28+ "macOS updates"
29+ ],
30+ [
31+ "Children's books",
32+ "Children's books"
33+ ],
34+ [
35+ "'Quoted' title words",
36+ "Quoted title words"
37+ ],
38+ [
39+ "Planning a trip to Japan",
40+ "Planning a trip"
41+ ],
42+ [
43+ "Recipes for",
44+ null
45+ ],
46+ [
47+ "Python教程",
48+ "Python教程"
49+ ],
50+ [
51+ "React 教程",
52+ "React 教程"
53+ ],
54+ [
55+ "日本旅行",
56+ "日本旅行"
57+ ],
58+ [
59+ "京都の旅行計画",
60+ "京都の旅行計画"
61+ ],
62+ [
63+ "日本旅行。",
64+ "日本旅行"
65+ ],
66+ [
67+ "这是一个非常非常非常非常非常长的标题名字",
68+ "这是一个非常非常非常非常非常长的"
69+ ],
70+ [
71+ "Kubernetes 集群部署与运维指南 最佳实践",
72+ "Kubernetes 集群部署与运维指南"
73+ ],
74+ [
75+ "ทริปญี่ปุ่น",
76+ "ทริปญี่ปุ่น"
77+ ],
78+ [
79+ "日",
80+ null
81+ ],
82+ [
83+ "Tabs",
84+ null
85+ ],
86+ [
87+ "Misc stuff",
88+ null
89+ ],
90+ [
91+ "",
92+ null
93+ ],
94+ [
95+ "x",
96+ null
97+ ],
98+ [
99+ "Supercalifragilisticexpialidocious extraordinarily",
100+ null
101+ ]
102+]
host/tests/test_prompts_validate.py+7−7
@@ -1,3 +1,4 @@
1+import json
1import os
2import os
2import unittest
3import unittest
3
4
@@ -73,13 +74,12 @@ class ValidateName(unittest.TestCase):
73 with self.assertRaises(validate.ValidationError, msg=bad):
74 with self.assertRaises(validate.ValidationError, msg=bad):
74 self.v(bad)
75 self.v(bad)
75
76
76− def test_chinese_and_japanese_titles_need_no_spaces(self):
77+ def test_same_labels_as_the_extension(self):
77− self.assertEqual(self.v("日本旅行")["title"], "日本旅行")
78+ # test/url.test.ts runs these cases against src/shared/label.ts.
78− self.assertEqual(self.v("京都の旅行計画")["title"], "京都の旅行計画")
79+ with open(os.path.join(GOLDEN, "labels.json"), encoding="utf-8") as f:
79− self.assertEqual(self.v("React 教程")["title"], "React 教程")
80+ cases = json.load(f)
80− for bad in ("日", "这是一个非常非常非常非常非常长的标题名字"):
81+ for raw, fixed in cases:
81− with self.assertRaises(validate.ValidationError, msg=bad):
82+ self.assertEqual(validate.repair_label(raw), fixed, msg=raw)
82− self.v(bad)
83
83
84 def test_emoji_default(self):
84 def test_emoji_default(self):
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
host/validate.py+48−10
@@ -7,7 +7,6 @@ from urllib.parse import urlsplit
7HERE = os.path.dirname(os.path.realpath(__file__))
7HERE = os.path.dirname(os.path.realpath(__file__))
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10−QUOTES = "\"'“”‘’«»`"
11TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
10TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
12
11
13
12
@@ -89,34 +88,73 @@ def default_emoji(items):
89 return CATEGORIES[category_for_host(majority)][0]
88 return CATEGORIES[category_for_host(majority)][0]
90
89
91
90
91+# Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
92+_QUOTE = re.compile(r"(?<![^\W\d_])['’]|['’](?![^\W\d_])|[\"“”‘«»`]")
93+# A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
94+DANGLING = {"a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"}
95+
96+
92def repair_label(raw):
97def repair_label(raw):
93− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
98+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars (16 wide characters
99+ for no-space scripts), drop a dangling "to"/"of", capitalise. Kept in step with src/shared/label.ts."""
94 if not isinstance(raw, str):
100 if not isinstance(raw, str):
95 return None
101 return None
96− s = raw.translate({ord(q): None for q in QUOTES})
102+ s = _QUOTE.sub("", raw)
97 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
98 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
99 words = words[:4]
105 words = words[:4]
106+ if _no_space(" ".join(words)):
107+ while len(words) > 1 and _width(" ".join(words)) > 16:
108+ words.pop()
109+ if _width(" ".join(words)) > 16:
110+ words = [_cut_to_width(" ".join(words), 16)]
111+ else:
100− while len(words) > 2 and len(" ".join(words)) > 28:
112+ while len(words) > 2 and len(" ".join(words)) > 28:
113+ words.pop()
114+ while len(words) > 1 and words[-1].lower() in DANGLING:
101 words.pop()
115 words.pop()
102 label = TRAILING_PUNCT.sub("", " ".join(words))
116 label = TRAILING_PUNCT.sub("", " ".join(words))
103 if not label:
117 if not label:
104 return None
118 return None
119+ # "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
120+ if not any(c.isupper() for c in words[0][1:]):
105− label = label[0].upper() + label[1:]
121+ label = label[0].upper() + label[1:]
106 return label if is_valid_label(label) else None
122 return label if is_valid_label(label) else None
107
123
108
124
109−# Chinese and Japanese titles have no spaces between words, so they're checked by length instead.
125+# Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
126+# with any of their characters ("Python教程") is checked by width instead of word count. Each such character
127+# is about as wide as two Latin letters on the tab strip.
128+NO_SPACE = re.compile(r"[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
129+# Chinese, Japanese and Korean, for the host's token estimate.
110CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
130CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
111
131
112
132
133+def _no_space(label):
134+ return bool(NO_SPACE.search(label))
135+
136+
137+def _width(label):
138+ wide = len(NO_SPACE.findall(label))
139+ return wide + (len(label) - wide) / 2
140+
141+
142+def _cut_to_width(label, most):
143+ out = ""
144+ for ch in label:
145+ if _width(out + ch) > most:
146+ break
147+ out += ch
148+ return out.strip()
149+
150+
113def is_valid_label(label):
151def is_valid_label(label):
114 words = label.split()
152 words = label.split()
115− cjk = len(CJK.findall(label)) * 2 > len(label.replace(" ", ""))
153+ wide = _no_space(label)
116 return (
154 return (
117− (2 <= len(label) <= 16 if cjk else 3 <= len(label) <= 28)
155+ (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
118− and (1 <= len(words) <= 4 if cjk else 2 <= len(words) <= 4)
156+ and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
119− and not any(q in label for q in QUOTES)
157+ and not _QUOTE.search(label)
120 and not TRAILING_PUNCT.search(label)
158 and not TRAILING_PUNCT.search(label)
121 and not any(w.lower() in BANNED_WORDS for w in words)
159 and not any(w.lower() in BANNED_WORDS for w in words)
122 )
160 )
src/background/host.ts+3
@@ -102,6 +102,9 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102 };
102 };
103}
103}
104
104
105+/** Errors about what one request carried, not about the host: they don't count toward pausing it. */
106+export const REQUEST_ERRORS = new Set<HostErrorCode>(["GUARDRAIL", "BAD_MODEL_OUTPUT", "OVER_BUDGET", "BAD_REQUEST"]);
107+
105/** Errors that mean "fix the setup", not "try this group again later". */
108/** Errors that mean "fix the setup", not "try this group again later". */
106export const SETUP_ERRORS = new Set<HostErrorCode>([
109export const SETUP_ERRORS = new Set<HostErrorCode>([
107 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+8−4
@@ -1,6 +1,6 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3−import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, 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";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6import { FitChecker } from "./fit";
6import { FitChecker } from "./fit";
@@ -93,7 +93,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
93 h.lastOkAt = Date.now();
93 h.lastOkAt = Date.now();
94 h.consecutiveFailures = 0;
94 h.consecutiveFailures = 0;
95 h.consecutiveTimeouts = 0;
95 h.consecutiveTimeouts = 0;
96− h.rateLimitStep = 0;
96+ if (op !== "ping") h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
97 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
97 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
98 } else {
98 } else {
99 recordHostError(reply.error);
99 recordHostError(reply.error);
@@ -107,6 +107,8 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
107}
107}
108
108
109function recordHostError(e: HostError): void {
109function recordHostError(e: HostError): void {
110+ // About one request's tabs, not the host's health: the caller retries or sets those tabs aside.
111+ if (REQUEST_ERRORS.has(e.code)) return;
110 const h = state.host;
112 const h = state.host;
111 const now = Date.now();
113 const now = Date.now();
112 h.lastError = { ...e, at: now };
114 h.lastError = { ...e, at: now };
@@ -161,7 +163,6 @@ function hostUnhealthy(): boolean {
161 const h = state.host;
163 const h = state.host;
162 const now = Date.now();
164 const now = Date.now();
163 if (h.pausedUntil && h.pausedUntil > now) return true;
165 if (h.pausedUntil && h.pausedUntil > now) return true;
164− if (h.consecutiveFailures >= 3) return true;
165 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
166 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
166}
167}
167
168
@@ -601,9 +602,12 @@ async function rewriteTitles(before: Settings): Promise<void> {
601async function nameGroupNow(groupId: number): Promise<void> {
602async function nameGroupNow(groupId: number): Promise<void> {
602 const g = state.groups[groupId];
603 const g = state.groups[groupId];
603 if (!g) return;
604 if (!g) return;
604− // An explicit request overrides "don't name" and a hand-typed title for this group.
605+ // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
606+ // answer goes on the strip even when it is close to the label it gave before.
605 g.managed = true;
607 g.managed = true;
606 g.userNamed = false;
608 g.userNamed = false;
609+ g.title = undefined;
610+ g.lastNamedAt = undefined;
607 commit();
611 commit();
608 await naming.nameNow(groupId);
612 await naming.nameNow(groupId);
609}
613}
src/background/naming.ts+62−23
@@ -1,6 +1,7 @@
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
4import type { HostOpts, HostReply } from "./host";
5import type { HostOpts, HostReply } from "./host";
5import { SETUP_ERRORS } from "./host";
6import { SETUP_ERRORS } from "./host";
6import type { Settings } from "./settings";
7import type { Settings } from "./settings";
@@ -113,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
113 if (g.userNamed) return false;
114 if (g.userNamed) return false;
114 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115 if (sameLabel(label, g.title)) return false;
116 if (sameLabel(label, g.title)) return false;
117+ // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
116− const before = new Set(g.memberUrls ?? []);
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
117− const after = new Set(urls);
119+ const after = new Set(urls.map(pathKey));
118 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
119 return changed; // a paraphrase with the same members keeps the old title
121 return changed; // a paraphrase with the same members keeps the old title
120}
122}
@@ -214,18 +216,32 @@ export class Naming {
214 }
216 }
215 const now = this.d.now();
217 const now = this.d.now();
216 if (!force) {
218 if (!force) {
219+ // A refusal waits for the members to change, which only a look at them can tell.
217− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
218 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
219 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
220 }
223 }
221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
222 if (!members) return;
225 if (!members) return;
223− if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
226+ if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
224− if (!force && membersHash(members) === g.membersHash) {
227+ if (members.length < 2) {
228+ // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
229+ if (g.dirty && !force) {
225− g.dirty = false;
230+ g.dirty = false;
231+ this.d.commit();
232+ }
233+ return;
234+ }
235+ const hash = membersHash(members);
236+ if (!force && hash === g.membersHash) {
237+ settle(g);
226 this.d.commit();
238 this.d.commit();
227 return;
239 return;
228 }
240 }
241+ if (g.refusedHash && !force) {
242+ if (hash === g.refusedHash && g.nextAttemptAt && g.nextAttemptAt > now) return;
243+ clearFailures(g); // different tabs now: Apple's filter may well take them
244+ }
229 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
230 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
231 void this.pump();
247 void this.pump();
@@ -261,7 +277,7 @@ export class Naming {
261 if (!members || members.length < 2 || !members.every(settled)) return;
277 if (!members || members.length < 2 || !members.every(settled)) return;
262 const hash = membersHash(members);
278 const hash = membersHash(members);
263 if (!force && hash === g.membersHash) {
279 if (!force && hash === g.membersHash) {
264− g.dirty = false;
280+ settle(g);
265 this.d.commit();
281 this.d.commit();
266 return;
282 return;
267 }
283 }
@@ -298,7 +314,7 @@ export class Naming {
298 retried.output = true;
314 retried.output = true;
299 continue;
315 continue;
300 }
316 }
301− this.fail(g, e);
317+ this.fail(g, e, hash);
302 return;
318 return;
303 }
319 }
304
320
@@ -309,7 +325,7 @@ export class Naming {
309 retried.output = true;
325 retried.output = true;
310 continue;
326 continue;
311 }
327 }
312− this.fail(g, e);
328+ this.fail(g, e, hash);
313 return;
329 return;
314 }
330 }
315 const emoji = safeEmoji(reply.result.emoji);
331 const emoji = safeEmoji(reply.result.emoji);
@@ -324,12 +340,15 @@ export class Naming {
324 finalLabel = suffixLabel(label, siblings);
340 finalLabel = suffixLabel(label, siblings);
325 }
341 }
326 this.d.hostOk();
342 this.d.hostOk();
327− await this.apply(g, finalLabel, emoji, members, hash);
343+ const written = await this.apply(g, finalLabel, emoji, members, hash);
328 g = this.d.state().groups[groupId];
344 g = this.d.state().groups[groupId];
329− if (g) {
345+ if (!g) return;
346+ if (!written) {
347+ // The strip refused the title (a drag in progress, the window closing): try again shortly.
348+ g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
349+ this.d.scheduleFallback(g.nextAttemptAt);
330− if (g.dirtySeq === seq) g.dirty = false;
350+ } else if (g.dirtySeq === seq) g.dirty = false;
331− else this.touch(groupId); // dirtied while in flight: go again with the new membership
351+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
332− }
333 return;
352 return;
334 }
353 }
335 } finally {
354 } finally {
@@ -340,16 +359,20 @@ export class Naming {
340 }
359 }
341 }
360 }
342
361
362+ /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
343− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
363+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
344 const settings = this.d.settings();
364 const settings = this.d.settings();
345 const urls = memberUrls(members);
365 const urls = memberUrls(members);
346− if (g.userNamed) return; // renamed by hand while the model was thinking
366+ if (g.userNamed) return true; // renamed by hand while the model was thinking
347 if (shouldApply(g, label, urls)) {
367 if (shouldApply(g, label, urls)) {
348 const title = stripTitle(label, emoji, settings.emoji);
368 const title = stripTitle(label, emoji, settings.emoji);
349 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
369 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
350 this.d.commit();
370 this.d.commit();
351 const ok = await this.d.writeTitle(g.id, title);
371 const ok = await this.d.writeTitle(g.id, title);
352− if (!ok) return;
372+ if (!ok) {
373+ delete this.d.state().ownWrites[g.id];
374+ return false;
375+ }
353 g.title = label;
376 g.title = label;
354 g.emoji = emoji;
377 g.emoji = emoji;
355 g.stripTitle = title;
378 g.stripTitle = title;
@@ -357,21 +380,24 @@ export class Naming {
357 g.lastNamedAt = this.d.now();
380 g.lastNamedAt = this.d.now();
358 g.membersHash = hash;
381 g.membersHash = hash;
359 g.memberUrls = urls;
382 g.memberUrls = urls;
360− g.nameAttempts = 0;
383+ clearFailures(g);
361− g.firstFailureAt = undefined;
384+ return true;
362− g.nextAttemptAt = undefined;
363 }
385 }
364
386
365− private fail(g: GroupRecord, e: HostError): void {
387+ private fail(g: GroupRecord, e: HostError, hash: string): void {
366 const now = this.d.now();
388 const now = this.d.now();
367 this.d.hostFailed(e);
389 this.d.hostFailed(e);
368 if (e.code === "BAD_REQUEST") {
390 if (e.code === "BAD_REQUEST") {
369 g.dirty = false; // a bug on our side: log and drop
391 g.dirty = false; // a bug on our side: log and drop
370 return;
392 return;
371 }
393 }
394+ // Setup errors pause every model call until the host is fixed, and its recovery picks this group up
395+ // again: they say nothing about this group, so they don't use up its retries.
396+ if (SETUP_ERRORS.has(e.code)) return;
372 if (e.code === "GUARDRAIL") {
397 if (e.code === "GUARDRAIL") {
373 g.nameAttempts = MAX_ATTEMPTS;
398 g.nameAttempts = MAX_ATTEMPTS;
374 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
399 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
400+ g.refusedHash = hash;
375 this.d.scheduleFallback(g.nextAttemptAt);
401 this.d.scheduleFallback(g.nextAttemptAt);
376 return;
402 return;
377 }
403 }
@@ -381,11 +407,24 @@ export class Naming {
381 g.dirty = false; // retried for a day: keep the provisional title
407 g.dirty = false; // retried for a day: keep the provisional title
382 return;
408 return;
383 }
409 }
384− const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
385− g.nextAttemptAt = now + wait;
410+ g.nextAttemptAt = now + backoffFor(g.nameAttempts);
386 this.d.scheduleFallback(g.nextAttemptAt);
411 this.d.scheduleFallback(g.nextAttemptAt);
387 }
412 }
388}
413}
389
414
415+/** The retry bookkeeping of earlier failures, which no longer applies. */
416+function clearFailures(g: GroupRecord): void {
417+ g.nameAttempts = 0;
418+ g.firstFailureAt = undefined;
419+ g.nextAttemptAt = undefined;
420+ g.refusedHash = undefined;
421+}
422+
423+/** The members are the ones last named: nothing to do, and nothing left to retry. */
424+function settle(g: GroupRecord): void {
425+ g.dirty = false;
426+ clearFailures(g);
427+}
428+
390export const eligible = (g: GroupRecord, settings: Settings): boolean =>
429export const eligible = (g: GroupRecord, settings: Settings): boolean =>
391 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
430 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/state.ts+1
@@ -47,6 +47,7 @@ export interface GroupRecord {
47 nameAttempts: number;
47 nameAttempts: number;
48 firstFailureAt?: number;
48 firstFailureAt?: number;
49 nextAttemptAt?: number;
49 nextAttemptAt?: number;
50+ refusedHash?: string; // the members Apple's filter refused to name: not asked again until they change
50 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
51 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
51 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
52 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
52 restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
53 restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
src/shared/health.ts+1−1
@@ -80,7 +80,7 @@ export function notices(s: Health): Notice[] {
80 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
80 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
81 }
81 }
82 }
82 }
83− if (s.timeoutHint && s.health !== "degraded") {
83+ if (s.timeoutHint) {
84 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
84 out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
85 }
85 }
86 return out;
86 return out;
src/shared/label.ts+35−9
@@ -7,9 +7,12 @@ export const LABEL_MAX_CHARS = 28;
7export const LABEL_MIN_WORDS = 2;
7export const LABEL_MIN_WORDS = 2;
8export const LABEL_MAX_WORDS = 4;
8export const LABEL_MAX_WORDS = 4;
9
9
10+// Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
10−const QUOTES = /["'“”‘’«»`]/g;
11+const QUOTES = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/gu;
11−const HAS_QUOTE = /["'“”‘’«»`]/;
12+const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
12const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
13const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
14+// A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
15+const DANGLING = new Set(["a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"]);
13
16
14/** Repair what is repairable; return undefined when the label cannot be made valid. */
17/** Repair what is repairable; return undefined when the label cannot be made valid. */
15export function repairLabel(raw: unknown): string | undefined {
18export function repairLabel(raw: unknown): string | undefined {
@@ -17,26 +20,49 @@ export function repairLabel(raw: unknown): string | undefined {
17 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
20 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
18 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
21 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
19 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
22 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
23+ if (noSpace(words.join(" "))) {
24+ while (words.length > 1 && widthOf(words.join(" ")) > CJK_MAX_CHARS) words.pop();
25+ if (widthOf(words.join(" ")) > CJK_MAX_CHARS) words = [cutToWidth(words.join(" "), CJK_MAX_CHARS)];
26+ } else {
20− while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
27+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
28+ }
29+ while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop();
21 let label = words.join(" ").replace(TRAILING_PUNCT, "");
30 let label = words.join(" ").replace(TRAILING_PUNCT, "");
22 if (!label) return undefined;
31 if (!label) return undefined;
32+ // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
23− label = label[0].toLocaleUpperCase() + label.slice(1);
33+ if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
24 return isValidLabel(label) ? label : undefined;
34 return isValidLabel(label) ? label : undefined;
25}
35}
26
36
37+/**
27−/** Chinese, Japanese and Korean titles have no spaces between words, so they're checked by length instead. */
38+ * Chinese, Japanese, Korean, Thai, Lao, Khmer and Myanmar titles don't put spaces between words, so a label
39+ * with any of their characters ("Python教程") is checked by width instead of word count. Each such character
40+ * is about as wide as two Latin letters on the tab strip.
41+ */
28−const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
42+const NO_SPACE = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
29export const CJK_MIN_CHARS = 2;
43export const CJK_MIN_CHARS = 2;
30export const CJK_MAX_CHARS = 16;
44export const CJK_MAX_CHARS = 16;
31
45
32−const isCjk = (label: string): boolean => (label.match(CJK)?.length ?? 0) * 2 > label.replace(/ /g, "").length;
46+const noSpace = (label: string): boolean => (label.match(NO_SPACE)?.length ?? 0) > 0;
47+const widthOf = (label: string): number => {
48+ const wide = label.match(NO_SPACE)?.length ?? 0;
49+ return wide + (label.length - wide) / 2;
50+};
51+const cutToWidth = (label: string, max: number): string => {
52+ let out = "";
53+ for (const ch of label) {
54+ if (widthOf(out + ch) > max) break;
55+ out += ch;
56+ }
57+ return out.trim();
58+};
33
59
34export function isValidLabel(label: string): boolean {
60export function isValidLabel(label: string): boolean {
35 const words = label.split(" ").filter(Boolean);
61 const words = label.split(" ").filter(Boolean);
36− const cjk = isCjk(label);
62+ const cjk = noSpace(label);
37 return (
63 return (
38 label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
64 label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
39− label.length <= (cjk ? CJK_MAX_CHARS : LABEL_MAX_CHARS) &&
65+ (cjk ? widthOf(label) <= CJK_MAX_CHARS && label.length <= LABEL_MAX_CHARS : label.length <= LABEL_MAX_CHARS) &&
40 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
66 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
41 words.length <= LABEL_MAX_WORDS &&
67 words.length <= LABEL_MAX_WORDS &&
42 !HAS_QUOTE.test(label) &&
68 !HAS_QUOTE.test(label) &&
test/naming.test.ts+64−2
@@ -50,6 +50,10 @@ describe("evolving-name policy", () => {
50 it("same label is never rewritten", () => {
50 it("same label is never rewritten", () => {
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
52 });
52 });
53+ it("a member that only moved within its page is not a membership change", () => {
54+ const g2 = newGroupRecord(1, 1, "opener", "blue", { title: "Rust async runtimes", lastNamedAt: 1, memberUrls: ["https://a.com/x", "https://b.com/y"] });
55+ expect(shouldApply(g2, "Tokio and async-std", ["https://a.com/x?page=2", "https://b.com/y#top"])).toBe(false);
56+ });
53 it("user-named groups are skipped", () => {
57 it("user-named groups are skipped", () => {
54 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
58 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
55 });
59 });
@@ -151,15 +155,25 @@ describe("naming queue", () => {
151 expect(state.groups[7].dirty).toBe(false);
155 expect(state.groups[7].dirty).toBe(false);
152 });
156 });
153
157
154− it("waits while a member is still loading or the group has one tab", async () => {
158+ it("waits while a member is still loading", async () => {
155 members = [member(1), member(2, { status: "loading" })];
159 members = [member(1), member(2, { status: "loading" })];
156 naming.touch(7);
160 naming.touch(7);
157 await settle();
161 await settle();
162+ expect(calls).toHaveLength(0);
163+ expect(state.groups[7].dirty).toBe(true);
164+ });
165+
166+ it("a group with one tab to name from is not kept waiting; the next tab to join names it", async () => {
158 members = [member(1)];
167 members = [member(1)];
159 naming.touch(7);
168 naming.touch(7);
160 await settle();
169 await settle();
161 expect(calls).toHaveLength(0);
170 expect(calls).toHaveLength(0);
162− expect(state.groups[7].dirty).toBe(true);
171+ expect(state.groups[7].dirty).toBe(false);
172+ members = [member(1), member(2)];
173+ markDirty(state.groups[7], 0);
174+ naming.touch(7);
175+ await settle();
176+ expect(calls).toHaveLength(1);
163 });
177 });
164
178
165 it("names a group whose member the browser discarded to save memory", async () => {
179 it("names a group whose member the browser discarded to save memory", async () => {
@@ -206,6 +220,54 @@ describe("naming queue", () => {
206 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
207 });
221 });
208
222
223+ it("a group Apple's filter refused is asked again as soon as its tabs change, not only after a day", async () => {
224+ replies.push({ ok: false, error: { code: "GUARDRAIL", message: "no" } }, { ok: false, error: { code: "GUARDRAIL", message: "no" } });
225+ naming.touch(7);
226+ await settle();
227+ expect(calls).toHaveLength(2);
228+ naming.touch(7);
229+ await settle();
230+ expect(calls).toHaveLength(2); // the same tabs: still refused
231+ members = [member(1), member(3)];
232+ naming.touch(7);
233+ await settle();
234+ expect(calls).toHaveLength(3);
235+ expect(state.groups[7]).toMatchObject({ title: "Rust crate docs", dirty: false, nameAttempts: 0 });
236+ expect(state.groups[7].refusedHash).toBeUndefined();
237+ });
238+
239+ it("setup errors don't use up a group's retries; the host's recovery picks it up", async () => {
240+ replies.push({ ok: false, error: { code: "LICENSE_REQUIRED", message: "terms" } });
241+ naming.touch(7);
242+ await settle();
243+ expect(state.groups[7]).toMatchObject({ dirty: true, nameAttempts: 0 });
244+ expect(state.groups[7].firstFailureAt).toBeUndefined();
245+ expect(state.groups[7].nextAttemptAt).toBeUndefined();
246+ });
247+
248+ it("a title the strip would not take is tried again shortly", async () => {
249+ let refuse = true;
250+ naming = new Naming({ ...(naming as any).d, writeTitle: async () => !refuse });
251+ naming.touch(7);
252+ await settle();
253+ expect(state.groups[7]).toMatchObject({ dirty: true, nextAttemptAt: now + 30_000 });
254+ expect(state.groups[7].title).toBeUndefined();
255+ expect(state.ownWrites[7]).toBeUndefined();
256+ refuse = false;
257+ now += 30_000;
258+ await naming.consider(7);
259+ await settle();
260+ expect(state.groups[7]).toMatchObject({ dirty: false, title: "Rust crate docs" });
261+ });
262+
263+ it("members back as they were last named clear old failures", async () => {
264+ Object.assign(state.groups[7], { membersHash: membersHash(members), nameAttempts: 3, firstFailureAt: 1, nextAttemptAt: 1 });
265+ naming.touch(7);
266+ await settle();
267+ expect(state.groups[7]).toMatchObject({ dirty: false, nameAttempts: 0 });
268+ expect(state.groups[7].firstFailureAt).toBeUndefined();
269+ });
270+
209 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
210 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
211 replies.push(fail);
273 replies.push(fail);
test/url.test.ts+6−12
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
2import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
4import { withDefaults } from "../src/background/settings";
4import { withDefaults } from "../src/background/settings";
5+import labelCases from "../host/tests/golden/labels.json";
5
6
6describe("url helpers", () => {
7describe("url helpers", () => {
7 it("strips www.", () => {
8 it("strips www.", () => {
@@ -45,19 +46,12 @@ describe("url helpers", () => {
45 });
46 });
46});
47});
47
48
48−describe("labels", () => {
49+// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
49− it.each([
50+const LABEL_CASES = labelCases as [string, string | null][];
50− ['"Rust async runtimes."', "Rust async runtimes"],
51− ["rust async runtimes", "Rust async runtimes"],
52− ["Rust tabs and docs", "Rust and docs"],
53− ["One two three four five six", "One two three four"],
54− ["Machine learning research papers reading list", "Machine learning research"],
55− ])("repairs %s", (raw, fixed) => {
56− expect(repairLabel(raw)).toBe(fixed);
57− });
58
51
59− it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
52+describe("labels", () => {
53+ it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
60− expect(repairLabel(raw)).toBeUndefined();
54+ expect(repairLabel(raw) ?? null).toBe(fixed);
61 });
55 });
62
56
63 it("validates", () => {
57 it("validates", () => {
tsconfig.json+1
@@ -9,6 +9,7 @@
9 "noEmit": true,
9 "noEmit": true,
10 "skipLibCheck": true,
10 "skipLibCheck": true,
11 "isolatedModules": true,
11 "isolatedModules": true,
12+ "resolveJsonModule": true,
12 "noFallthroughCasesInSwitch": true,
13 "noFallthroughCasesInSwitch": true,
13 "forceConsistentCasingInFileNames": true
14 "forceConsistentCasingInFileNames": true
14 },
15 },