on master help sign in
Stop one hard-to-name group from pausing Diagonal, and accept mixed-script titles

A label the model kept getting wrong, or a page Apple's filter refused, counted as the helper failing: five of them paused all naming and sorting for ten minutes, and three showed a red "Paused" badge while nothing was paused. Those errors now stay with the group or tabs they came from, the badge turns red only while Diagonal is really paused or needs setup, and the slow-model hint shows even when it is. A ping no longer resets the rate-limit backoff. Naming: - a group Apple's filter refused is asked again as soon as its tabs change, not only after a day - setup errors (terms not accepted, host missing) no longer use up a group's retries; the helper's recovery picks the group up - a title the tab strip would not take is tried again in 30 s instead of being dropped - members back to how they were last named clear old failures - a group with one tab to name from is no longer kept dirty forever - "Name now" writes the model's answer even when it is close to the old one - a member moving to ?page=2 or #comments is not a membership change Labels, the same in the extension and the host (shared cases in host/tests/golden/labels.json): - any Chinese, Japanese, Korean, Thai, Lao, Khmer or Myanmar character uses the width rule, so "Python教程" is accepted; over-long ones are cut - "iPhone" and "macOS" keep their casing, "Children's" keeps its apostrophe - a cut label doesn't end on "to", "of" or "and" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commit9dba9d5109cd156aa83250612d71de54e474f472 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 11:49 (2 weeks ago) parent333a70e
12 files changed+338−68
host/tests/golden/labels.json+102 host/tests/test_prompts_validate.py+7−7 host/validate.py+48−10 src/background/host.ts+3 src/background/index.ts+8−4 src/background/naming.ts+62−23 src/background/state.ts+1 src/shared/health.ts+1−1 src/shared/label.ts+35−9 test/naming.test.ts+64−2 test/url.test.ts+6−12 tsconfig.json+1
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
12import os
23import unittest
34
@@ -73,13 +74,12 @@ class ValidateName(unittest.TestCase):
7374 with self.assertRaises(validate.ValidationError, msg=bad):
7475 self.v(bad)
7576
76− def test_chinese_and_japanese_titles_need_no_spaces(self):
77− self.assertEqual(self.v("日本旅行")["title"], "日本旅行")
78− self.assertEqual(self.v("京都の旅行計画")["title"], "京都の旅行計画")
79− self.assertEqual(self.v("React 教程")["title"], "React 教程")
80− for bad in ("日", "这是一个非常非常非常非常非常长的标题名字"):
81− with self.assertRaises(validate.ValidationError, msg=bad):
82− self.v(bad)
77+ def test_same_labels_as_the_extension(self):
78+ # test/url.test.ts runs these cases against src/shared/label.ts.
79+ with open(os.path.join(GOLDEN, "labels.json"), encoding="utf-8") as f:
80+ cases = json.load(f)
81+ for raw, fixed in cases:
82+ self.assertEqual(validate.repair_label(raw), fixed, msg=raw)
8383
8484 def test_emoji_default(self):
8585 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
host/validate.py+48−10
@@ -7,7 +7,6 @@ from urllib.parse import urlsplit
77HERE = os.path.dirname(os.path.realpath(__file__))
88COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
99BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10−QUOTES = "\"'“”‘’«»`"
1110TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
1211
1312
@@ -89,34 +88,73 @@ def default_emoji(items):
8988 return CATEGORIES[category_for_host(majority)][0]
9089
9190
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+
9297def 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."""
94100 if not isinstance(raw, str):
95101 return None
96− s = raw.translate({ord(q): None for q in QUOTES})
102+ s = _QUOTE.sub("", raw)
97103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
98104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
99105 words = words[:4]
100− while len(words) > 2 and len(" ".join(words)) > 28:
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:
112+ while len(words) > 2 and len(" ".join(words)) > 28:
113+ words.pop()
114+ while len(words) > 1 and words[-1].lower() in DANGLING:
101115 words.pop()
102116 label = TRAILING_PUNCT.sub("", " ".join(words))
103117 if not label:
104118 return None
105− label = label[0].upper() + label[1:]
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:]):
121+ label = label[0].upper() + label[1:]
106122 return label if is_valid_label(label) else None
107123
108124
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.
110130CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
111131
112132
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+
113151def is_valid_label(label):
114152 words = label.split()
115− cjk = len(CJK.findall(label)) * 2 > len(label.replace(" ", ""))
153+ wide = _no_space(label)
116154 return (
117− (2 <= len(label) <= 16 if cjk else 3 <= len(label) <= 28)
118− and (1 <= len(words) <= 4 if cjk else 2 <= len(words) <= 4)
119− and not any(q in label for q in QUOTES)
155+ (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
156+ and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
157+ and not _QUOTE.search(label)
120158 and not TRAILING_PUNCT.search(label)
121159 and not any(w.lower() in BANNED_WORDS for w in words)
122160 )
src/background/host.ts+3
@@ -102,6 +102,9 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102102 };
103103}
104104
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+
105108/** Errors that mean "fix the setup", not "try this group again later". */
106109export const SETUP_ERRORS = new Set<HostErrorCode>([
107110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+8−4
@@ -1,6 +1,6 @@
11import { stripTitle } from "../shared/label";
22import { 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";
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";
@@ -93,7 +93,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9393 h.lastOkAt = Date.now();
9494 h.consecutiveFailures = 0;
9595 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
9797 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
9898 } else {
9999 recordHostError(reply.error);
@@ -107,6 +107,8 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
107107}
108108
109109function 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;
110112 const h = state.host;
111113 const now = Date.now();
112114 h.lastError = { ...e, at: now };
@@ -161,7 +163,6 @@ function hostUnhealthy(): boolean {
161163 const h = state.host;
162164 const now = Date.now();
163165 if (h.pausedUntil && h.pausedUntil > now) return true;
164− if (h.consecutiveFailures >= 3) return true;
165166 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
166167}
167168
@@ -601,9 +602,12 @@ async function rewriteTitles(before: Settings): Promise<void> {
601602async function nameGroupNow(groupId: number): Promise<void> {
602603 const g = state.groups[groupId];
603604 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.
605607 g.managed = true;
606608 g.userNamed = false;
609+ g.title = undefined;
610+ g.lastNamedAt = undefined;
607611 commit();
608612 await naming.nameNow(groupId);
609613}
src/background/naming.ts+62−23
@@ -1,6 +1,7 @@
11import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
45import type { HostOpts, HostReply } from "./host";
56import { SETUP_ERRORS } from "./host";
67import type { Settings } from "./settings";
@@ -113,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
113114 if (g.userNamed) return false;
114115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115116 if (sameLabel(label, g.title)) return false;
116− const before = new Set(g.memberUrls ?? []);
117− const after = new Set(urls);
117+ // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
119+ const after = new Set(urls.map(pathKey));
118120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
119121 return changed; // a paraphrase with the same members keeps the old title
120122}
@@ -214,18 +216,32 @@ export class Naming {
214216 }
215217 const now = this.d.now();
216218 if (!force) {
217− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
219+ // A refusal waits for the members to change, which only a look at them can tell.
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
218221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
219222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
220223 }
221224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
222225 if (!members) return;
223− if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
224− if (!force && membersHash(members) === g.membersHash) {
225− g.dirty = false;
226+ if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
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) {
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);
226238 this.d.commit();
227239 return;
228240 }
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+ }
229245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
230246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
231247 void this.pump();
@@ -261,7 +277,7 @@ export class Naming {
261277 if (!members || members.length < 2 || !members.every(settled)) return;
262278 const hash = membersHash(members);
263279 if (!force && hash === g.membersHash) {
264− g.dirty = false;
280+ settle(g);
265281 this.d.commit();
266282 return;
267283 }
@@ -298,7 +314,7 @@ export class Naming {
298314 retried.output = true;
299315 continue;
300316 }
301− this.fail(g, e);
317+ this.fail(g, e, hash);
302318 return;
303319 }
304320
@@ -309,7 +325,7 @@ export class Naming {
309325 retried.output = true;
310326 continue;
311327 }
312− this.fail(g, e);
328+ this.fail(g, e, hash);
313329 return;
314330 }
315331 const emoji = safeEmoji(reply.result.emoji);
@@ -324,12 +340,15 @@ export class Naming {
324340 finalLabel = suffixLabel(label, siblings);
325341 }
326342 this.d.hostOk();
327− await this.apply(g, finalLabel, emoji, members, hash);
343+ const written = await this.apply(g, finalLabel, emoji, members, hash);
328344 g = this.d.state().groups[groupId];
329− if (g) {
330− if (g.dirtySeq === seq) g.dirty = false;
331− else this.touch(groupId); // dirtied while in flight: go again with the new membership
332− }
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);
350+ } else if (g.dirtySeq === seq) g.dirty = false;
351+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
333352 return;
334353 }
335354 } finally {
@@ -340,16 +359,20 @@ export class Naming {
340359 }
341360 }
342361
343− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
362+ /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
363+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
344364 const settings = this.d.settings();
345365 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
347367 if (shouldApply(g, label, urls)) {
348368 const title = stripTitle(label, emoji, settings.emoji);
349369 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
350370 this.d.commit();
351371 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+ }
353376 g.title = label;
354377 g.emoji = emoji;
355378 g.stripTitle = title;
@@ -357,21 +380,24 @@ export class Naming {
357380 g.lastNamedAt = this.d.now();
358381 g.membersHash = hash;
359382 g.memberUrls = urls;
360− g.nameAttempts = 0;
361− g.firstFailureAt = undefined;
362− g.nextAttemptAt = undefined;
383+ clearFailures(g);
384+ return true;
363385 }
364386
365− private fail(g: GroupRecord, e: HostError): void {
387+ private fail(g: GroupRecord, e: HostError, hash: string): void {
366388 const now = this.d.now();
367389 this.d.hostFailed(e);
368390 if (e.code === "BAD_REQUEST") {
369391 g.dirty = false; // a bug on our side: log and drop
370392 return;
371393 }
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;
372397 if (e.code === "GUARDRAIL") {
373398 g.nameAttempts = MAX_ATTEMPTS;
374399 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
400+ g.refusedHash = hash;
375401 this.d.scheduleFallback(g.nextAttemptAt);
376402 return;
377403 }
@@ -381,11 +407,24 @@ export class Naming {
381407 g.dirty = false; // retried for a day: keep the provisional title
382408 return;
383409 }
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);
386411 this.d.scheduleFallback(g.nextAttemptAt);
387412 }
388413}
389414
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+
390429export const eligible = (g: GroupRecord, settings: Settings): boolean =>
391430 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/state.ts+1
@@ -47,6 +47,7 @@ export interface GroupRecord {
4747 nameAttempts: number;
4848 firstFailureAt?: number;
4949 nextAttemptAt?: number;
50+ refusedHash?: string; // the members Apple's filter refused to name: not asked again until they change
5051 registeredAt?: number; // first seen as a group Diagonal did not know: it may yet turn out to be one of Diagonal's coming back
5152 revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
5253 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[] {
8080 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
8181 }
8282 }
83− if (s.timeoutHint && s.health !== "degraded") {
83+ if (s.timeoutHint) {
8484 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." });
8585 }
8686 return out;
src/shared/label.ts+35−9
@@ -7,9 +7,12 @@ export const LABEL_MAX_CHARS = 28;
77export const LABEL_MIN_WORDS = 2;
88export const LABEL_MAX_WORDS = 4;
99
10−const QUOTES = /["'“”‘’«»`]/g;
11−const HAS_QUOTE = /["'“”‘’«»`]/;
10+// Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
11+const QUOTES = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/gu;
12+const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
1213const 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"]);
1316
1417/** Repair what is repairable; return undefined when the label cannot be made valid. */
1518export function repairLabel(raw: unknown): string | undefined {
@@ -17,26 +20,49 @@ export function repairLabel(raw: unknown): string | undefined {
1720 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
1821 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
1922 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
20− while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
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 {
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();
2130 let label = words.join(" ").replace(TRAILING_PUNCT, "");
2231 if (!label) return undefined;
23− label = label[0].toLocaleUpperCase() + label.slice(1);
32+ // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
33+ if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
2434 return isValidLabel(label) ? label : undefined;
2535}
2636
27−/** Chinese, Japanese and Korean titles have no spaces between words, so they're checked by length instead. */
28−const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
37+/**
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+ */
42+const NO_SPACE = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
2943export const CJK_MIN_CHARS = 2;
3044export const CJK_MAX_CHARS = 16;
3145
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+};
3359
3460export function isValidLabel(label: string): boolean {
3561 const words = label.split(" ").filter(Boolean);
36− const cjk = isCjk(label);
62+ const cjk = noSpace(label);
3763 return (
3864 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) &&
4066 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
4167 words.length <= LABEL_MAX_WORDS &&
4268 !HAS_QUOTE.test(label) &&
test/naming.test.ts+64−2
@@ -50,6 +50,10 @@ describe("evolving-name policy", () => {
5050 it("same label is never rewritten", () => {
5151 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
5252 });
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+ });
5357 it("user-named groups are skipped", () => {
5458 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
5559 });
@@ -151,15 +155,25 @@ describe("naming queue", () => {
151155 expect(state.groups[7].dirty).toBe(false);
152156 });
153157
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 () => {
155159 members = [member(1), member(2, { status: "loading" })];
156160 naming.touch(7);
157161 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 () => {
158167 members = [member(1)];
159168 naming.touch(7);
160169 await settle();
161170 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);
163177 });
164178
165179 it("names a group whose member the browser discarded to save memory", async () => {
@@ -206,6 +220,54 @@ describe("naming queue", () => {
206220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
207221 });
208222
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+
209271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
210272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
211273 replies.push(fail);
test/url.test.ts+6−12
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
33import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
44import { withDefaults } from "../src/background/settings";
5+import labelCases from "../host/tests/golden/labels.json";
56
67describe("url helpers", () => {
78 it("strips www.", () => {
@@ -45,19 +46,12 @@ describe("url helpers", () => {
4546 });
4647});
4748
48−describe("labels", () => {
49− it.each([
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− });
49+// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
50+const LABEL_CASES = labelCases as [string, string | null][];
5851
59− it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
60− expect(repairLabel(raw)).toBeUndefined();
52+describe("labels", () => {
53+ it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
54+ expect(repairLabel(raw) ?? null).toBe(fixed);
6155 });
6256
6357 it("validates", () => {
tsconfig.json+1
@@ -9,6 +9,7 @@
99 "noEmit": true,
1010 "skipLibCheck": true,
1111 "isolatedModules": true,
12+ "resolveJsonModule": true,
1213 "noFallthroughCasesInSwitch": true,
1314 "forceConsistentCasingInFileNames": true
1415 },