on fae43a2 help sign in
25 files changed+284−1573
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+8−23 host/diagonal-host.py+30−49 host/tests/golden/labels.json−102 host/tests/test_host.py−56 host/tests/test_prompts_validate.py+7−7 host/validate.py+14−92 scripts/install-manifest.sh+2−11 src/background/engine.ts+5−101 src/background/host.ts−3 src/background/index.ts+36−121 src/background/naming.ts+25−77 src/background/organize.ts+107−194 src/background/runtime.ts+2−13 src/background/state.ts−18 src/background/tidy.ts+17−129 src/shared/health.ts+2−4 src/shared/label.ts+9−42 test/auto.test.ts+2−84 test/engine.test.ts−126 test/naming.test.ts+2−72 test/organize.test.ts−17 test/sweep.test.ts−181 test/tidy.test.ts+3−34 test/url.test.ts+13−16 tsconfig.json−1
.github/workflows/release.yml+8−23
@@ -9,11 +9,8 @@ on:
99permissions:
1010 contents: write
1111
12−# One release at a time, in push order: an older run finishing last must not become the latest release,
13−# which is what the Homebrew cask downloads.
1412concurrency:
15− group: release
16− cancel-in-progress: false
13+ group: release-${{ github.sha }}
1714
1815jobs:
1916 release:
@@ -35,23 +32,8 @@ jobs:
3532
3633 - name: Version from commit time
3734 id: version
38− env:
39− GH_TOKEN: ${{ github.token }}
4035 run: |
41− # The commit's minute, or the next free minute when another commit already released in it. A re-run
42− # for a commit that already released publishes nothing: a rebuilt zip has new bytes, which would
43− # break the sha256 the tap already has, and users on that version would never get the new one.
44− t=$(git log -1 --format=%ct "$GITHUB_SHA")
45− for _ in $(seq 1 30); do
46− version=$(date -u -d "@$t" +%Y.%m.%d.%H%M)
47− target=$(gh release view "$version" --json targetCommitish -q .targetCommitish 2>/dev/null || true)
48− [ -z "$target" ] && break
49− if [ "$target" = "$GITHUB_SHA" ]; then
50− echo "published=true" >> "$GITHUB_OUTPUT"
51− break
52− fi
53− t=$((t + 60))
54− done
36+ version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
5537 echo "version=$version" >> "$GITHUB_OUTPUT"
5638 echo "Version $version"
5739
@@ -72,19 +54,22 @@ jobs:
7254 npm run zip:store
7355
7456 - name: Publish release
75− if: steps.version.outputs.published != 'true'
7657 env:
7758 GH_TOKEN: ${{ github.token }}
7859 VERSION: ${{ steps.version.outputs.version }}
7960 run: |
8061 asset="diagonal-extension-${VERSION}.zip"
8162 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
82− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
63+ if gh release view "$VERSION" >/dev/null 2>&1; then
64+ # Two commits in the same minute share a version: refresh the asset on the existing release.
65+ gh release upload "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --clobber
66+ else
67+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
68+ fi
8369
8470 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
8571 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
8672 - name: Upload to the Chrome Web Store
87− if: steps.version.outputs.published != 'true'
8873 env:
8974 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
9075 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
host/diagonal-host.py+30−49
@@ -206,7 +206,7 @@ def count_tokens(prompt):
206206 """fm's exact count (under 0.1 s), or None when it can't give one."""
207207 try:
208208 p = subprocess.run([FM, "count-tokens", "-q", "-i", prompt.instructions], input=prompt.text,
209− capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, env=fm_env())
209+ capture_output=True, text=True, timeout=10, env=fm_env())
210210 except (OSError, subprocess.TimeoutExpired):
211211 return None
212212 out = p.stdout.strip()
@@ -255,41 +255,28 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255255USAGE_EXIT = 64
256256
257257
258−# Below this much time left in a request, a second fm call (the permissive retry, naming one more new
259−# group) is not started: it would only be cut off.
260−MIN_CALL_S = 5.0
261−
262−
263−def run_fm(prompt, schema, opts):
264− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin. Every call in one
265− request shares its deadline, so the extension's guard (its timeout plus 10 s) is a real upper bound."""
258+def run_fm(prompt, schema, model, timeout_s):
259+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
266260 try:
267− return _run_fm(prompt, schema, opts, [])
261+ return _run_fm(prompt, schema, model, timeout_s, [])
268262 except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
263+ if f.code != "GUARDRAIL":
270264 raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
265+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
276266
277267
278−def _run_fm(prompt, schema, opts, extra):
268+def _run_fm(prompt, schema, model, timeout_s, extra):
279269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
280270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
281271 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
282− # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them. The strict
283− # retry after an unusable answer samples instead: greedy would give the same answer again.
284− greedy = [] if opts.get("strict") else ["--greedy"]
285− args = [FM, "respond", "--model", opts["model"], "--no-stream", *greedy, "--schema", schema, "-i", prompt.instructions, *extra]
286− timeout_s = max(1.0, time_left(opts))
272+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
273+ args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
287274 try:
288− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
275+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
289276 except FileNotFoundError:
290277 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291278 except subprocess.TimeoutExpired:
292− raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
279+ raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
293280 if p.returncode != 0:
294281 code = classify(p.stderr, p.returncode)
295282 if code == "LICENSE_REQUIRED":
@@ -308,11 +295,11 @@ def organize_mode():
308295
309296def op_ping(_payload, _opts):
310297 if not os.path.exists(FM):
311− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
298+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
312299 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313300 license_required = False
314301 try:
315− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
302+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
316303 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317304 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318305 license_required, msg = True, LICENSE_MESSAGE
@@ -380,7 +367,7 @@ def op_name(payload, opts):
380367 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
381368 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
382369 try:
383− out = run_fm(prompt, schema, opts)
370+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
384371 except Fail as f:
385372 raise _fit_overflow(f, len(payload["items"]))
386373 try:
@@ -396,7 +383,7 @@ def op_organize(payload, opts):
396383 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
397384 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
398385 try:
399− out = run_fm(prompt, schema, opts)
386+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
400387 except Fail as f:
401388 raise _fit_overflow(f, len(payload["items"]))
402389 _, owner = prompts.topic_items(payload)
@@ -406,31 +393,29 @@ def op_organize(payload, opts):
406393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
407394 chars = prompts.size(prompt)
408395 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
409− chars += name_new_groups(result, payload, opts)
396+ chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
410397 return result, chars, "topics"
411398
412399
413−def name_new_groups(result, payload, opts):
400+def name_new_groups(result, payload, opts, deadline):
414401 """Title each new group with the name op (well under a second each on fm), so groups appear named.
415− A group that fails, runs past the request's deadline, or gets a title another group in the window
416− already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
417− taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
402+ A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
403+ taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
418404 chars = 0
419405 for g in result["groups"]:
420406 if "existing" in g or g.get("title"):
421407 continue
422− if time_left(opts) < MIN_CALL_S:
408+ left = deadline - time.time()
409+ if left < 5:
423410 break
424411 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425412 try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
413+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
427414 except Fail:
428415 continue
429− chars += n
430− if any(validate.same_label(named["title"], t) for t in taken):
431− continue
432416 g["title"], g["emoji"] = named["title"], named["emoji"]
433417 taken.append(named["title"])
418+ chars += n
434419 return chars
435420
436421
@@ -444,8 +429,8 @@ def handle(req):
444429 raw_opts = req.get("opts") or {}
445430 timeout_ms = raw_opts.get("timeoutMs", 45000)
446431 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
447− timeout_s = max(5.0, min(120.0, timeout_ms / 1000))
448− opts = {"model": "system", "strict": bool(raw_opts.get("strict")), "timeout_s": timeout_s, "deadline": t0 + timeout_s}
432+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
433+ "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
449434 op = req["op"]
450435 if op == "ping":
451436 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -499,7 +484,7 @@ def install_schemas():
499484 if "{%s}" % key not in args:
500485 continue
501486 try:
502− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
487+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
503488 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504489 print(f" {name}: fm failed ({e})", file=sys.stderr)
505490 return "error"
@@ -511,7 +496,7 @@ def install_schemas():
511496 subs["{%s}" % key] = p.stdout.strip()
512497 args = [subs.get(a, a) for a in args]
513498 try:
514− p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
499+ p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
515500 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516501 print(f" {name}: fm failed ({e})", file=sys.stderr)
517502 return "error"
@@ -568,16 +553,14 @@ def selftest():
568553
569554 ping = op_ping({}, {})
570555 check("fm found", os.path.exists(FM), FM)
571− if not os.path.exists(FM):
572− return 1 # nothing else can work without it
573− if ping.get("licenseRequired"):
556+ if ping["licenseRequired"]:
574557 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575558 else:
576559 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577560 for name in SCHEMA_COMMANDS:
578561 path = os.path.join(SCHEMAS, name)
579562 readable = os.path.isfile(path) and os.path.getsize(path) > 0
580− hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
563+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
581564 check(f"schema {name}", readable, path if readable else hint)
582565 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583566 if ping["fmAvailable"] and schema_path("name.json"):
@@ -687,9 +670,7 @@ def register(host_path):
687670 f.write("\n".join(written) + "\n")
688671 except OSError:
689672 pass
690− # One browser that could not be written (a locked folder, a stale profile) does not undo the others:
691− # the install goes on and the ones that failed were named above.
692− return 0 if len(dirs) - failures > 0 else 1
673+ return 1 if failures else 0
693674
694675
695676def unregister():
host/tests/golden/labels.jsondeleted−102
@@ -1,102 +0,0 @@
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_host.py−56
@@ -287,19 +287,6 @@ class ErrorCodes(HostCase):
287287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288288
289289
290− def test_strict_retry_samples_instead_of_repeating_the_greedy_answer(self):
291− self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
292− self.call("name", {"items": ITEMS3}, strict=True)
293− self.assertNotIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
294−
295− def test_permissive_retry_shares_the_request_deadline(self):
296− # The first call uses up the request's time: no second call runs past what the extension waits for.
297− self.control({"respond_queue": [{"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1, "sleep": 2.5}]})
298− r = self.call("name", {"items": ITEMS3}, timeoutMs=5000)
299− self.assertEqual(r["error"]["code"], "GUARDRAIL")
300− self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
301−
302−
303290class Organize(HostCase):
304291 ITEMS = ITEMS3 + [
305292 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -344,33 +331,6 @@ class Organize(HostCase):
344331 self.assertIn("Tokio", name_calls[0])
345332 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346333
347− def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348− self.control({"respond_queue": [
349− {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
350− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
351− ]})
352− r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
353− self.assertEqual(r["result"]["groups"][0]["title"], "")
354− self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
355−
356− def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357− existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358− {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359− # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360− self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361− r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362− self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363− items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364− self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365− r = self.call("organize", {"items": items, "existingGroups": existing})
366− self.assertNotIn("existing", r["result"]["groups"][0])
367−
368− def test_only_vague_topics_leave_every_tab_loose(self):
369− self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370− r = self.call("organize", {"items": ITEMS3})
371− self.assertTrue(r["ok"], r)
372− self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
373−
374334 def test_two_topics_joining_one_group_make_one_join(self):
375335 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376336 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
@@ -527,15 +487,6 @@ class Register(HostCase):
527487 self.assertEqual(p.returncode, 1)
528488 self.assertIn("missing or not executable", p.stderr)
529489
530− def test_one_browser_that_cannot_be_written_does_not_fail_the_rest(self):
531− chrome = self.make_profile("Google/Chrome")
532− brave = self.make_profile("BraveSoftware/Brave-Browser")
533− open(os.path.join(brave, "NativeMessagingHosts"), "w").close() # a file where the folder should be
534− p = self.run_copy("--register", self.host_copy)
535− self.assertEqual(p.returncode, 0, p.stderr)
536− self.assertIn("BraveSoftware/Brave-Browser: could not write", p.stderr)
537− self.assertTrue(os.path.exists(os.path.join(chrome, "NativeMessagingHosts", "io.diagonal.host.json")))
538−
539490 def test_no_browser_found(self):
540491 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541492 p = self.run_copy("--register", self.host_copy)
@@ -557,13 +508,6 @@ class SelfTest(HostCase):
557508 self.assertEqual(code, 1)
558509 self.assertIn("FAIL model available", p.stdout.decode())
559510
560− def test_selftest_without_fm_says_so_and_stops(self):
561− self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "no-such-fm")
562− code, _, p = self.run_host(args=["--selftest"])
563− self.assertEqual(code, 1)
564− self.assertIn("FAIL fm found", p.stdout.decode())
565− self.assertNotIn("Traceback", p.stderr.decode())
566−
567511 def test_selftest_names_the_license_fix(self):
568512 self.control({"license": True})
569513 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+7−7
@@ -1,4 +1,3 @@
1−import json
21import os
32import unittest
43
@@ -74,12 +73,13 @@ class ValidateName(unittest.TestCase):
7473 with self.assertRaises(validate.ValidationError, msg=bad):
7574 self.v(bad)
7675
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)
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)
8383
8484 def test_emoji_default(self):
8585 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
host/validate.py+14−92
@@ -7,6 +7,7 @@ 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 = "\"'“”‘’«»`"
1011TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
1112
1213
@@ -88,82 +89,39 @@ def default_emoji(items):
8889 return CATEGORIES[category_for_host(majority)][0]
8990
9091
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−
9792def repair_label(raw):
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."""
93+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
10094 if not isinstance(raw, str):
10195 return None
102− s = _QUOTE.sub("", raw)
96+ s = raw.translate({ord(q): None for q in QUOTES})
10397 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
10498 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
10599 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:
112− while len(words) > 2 and len(" ".join(words)) > 28:
113− words.pop()
114− while len(words) > 1 and words[-1].lower() in DANGLING:
100+ while len(words) > 2 and len(" ".join(words)) > 28:
115101 words.pop()
116102 label = TRAILING_PUNCT.sub("", " ".join(words))
117103 if not label:
118104 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:]):
121− label = label[0].upper() + label[1:]
105+ label = label[0].upper() + label[1:]
122106 return label if is_valid_label(label) else None
123107
124108
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.
109+# Chinese and Japanese titles have no spaces between words, so they're checked by length instead.
130110CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
131111
132112
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−
151113def is_valid_label(label):
152114 words = label.split()
153− wide = _no_space(label)
115+ cjk = len(CJK.findall(label)) * 2 > len(label.replace(" ", ""))
154116 return (
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)
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)
158120 and not TRAILING_PUNCT.search(label)
159121 and not any(w.lower() in BANNED_WORDS for w in words)
160122 )
161123
162124
163−def same_label(a, b):
164− return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165−
166−
167125def validate_name(out, payload):
168126 if not isinstance(out, dict):
169127 raise ValidationError("reply is not an object")
@@ -269,52 +227,16 @@ def organize_from_topics(out, payload, owner):
269227 votes = group_votes.setdefault(topic, {})
270228 votes[owner[i]] = votes.get(owner[i], 0) + 1
271229 if not tab_topic:
272− if not any(isinstance(t, dict) and _in_range(t.get("index"), n_items) for t in out["tabs"]):
273− raise ValidationError("no tab got a topic")
274− # Every tab got only a vague topic ("Other", "Misc"): the model found nothing in common.
275− return validate_organize({"groups": [], "leftovers": []}, payload)
230+ raise ValidationError("no tab got a topic")
276231 clusters = {}
277232 for i in sorted(tab_topic):
278233 clusters.setdefault(tab_topic[i], []).append(i)
279234 groups = []
280235 for topic, members in clusters.items():
281− g = _winner(group_votes.get(topic) or {}, members, payload)
282− if g is not None:
236+ votes = group_votes.get(topic)
237+ if votes:
238+ g = max(sorted(votes), key=lambda k: votes[k])
283239 groups.append({"existing": g, "members": members})
284240 else:
285241 groups.append({"title": "", "emoji": "", "color": "", "members": members})
286242 return validate_organize({"groups": groups, "leftovers": []}, payload)
287−
288−
289−def _in_range(i, n):
290− return isinstance(i, int) and not isinstance(i, bool) and 0 <= i < n
291−
292−
293−_WORD = re.compile(r"\w{3,}")
294−
295−
296−def _words(text):
297− return {w.casefold() for w in _WORD.findall(text or "")}
298−
299−
300−def _winner(votes, members, payload):
301− """The existing group a topic's tabs join: the one whose example tabs share it most. When two groups tie,
302− the one whose title and examples share more words with these tabs; when that ties too, none (the tabs
303− form a group of their own, or stay loose), rather than whichever group happens to be listed first."""
304− if not votes:
305− return None
306− top = max(votes.values())
307− tied = sorted(k for k, v in votes.items() if v == top)
308− if len(tied) == 1:
309− return tied[0]
310− tab_words = set().union(*(_words(payload["items"][i].get("title")) for i in members))
311− groups = {g["g"]: g for g in payload.get("existingGroups") or []}
312−
313− def overlap(k):
314− g = groups.get(k) or {}
315− return len(tab_words & _words(" ".join([g.get("title") or "", *(g.get("samples") or [])])))
316−
317− scores = {k: overlap(k) for k in tied}
318− best = max(scores.values())
319− leaders = [k for k in tied if scores[k] == best]
320− return leaders[0] if len(leaders) == 1 and best > 0 else None
scripts/install-manifest.sh+2−11
@@ -53,16 +53,7 @@ echo "host: $BIN -> $SHARE/diagonal-host.py"
5353"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
5454# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
5555# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56−# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
57−# says how to finish.
58−register_rc=0
59−"$BIN" --register "$BIN" || register_rc=$?
60−register_note() {
61− if [[ $register_rc -ne 0 ]]; then
62− echo "No browser could be set up for Diagonal yet. Open your browser once, then run: bash '$HERE/$(basename "$0")'"
63− fi
64−}
65−trap register_note EXIT
56+"$BIN" --register "$BIN"
6657
6758FM="${DIAGONAL_FM:-/usr/bin/fm}"
6859
@@ -98,7 +89,7 @@ fi
9889# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
9990schemas_out=$("$BIN" --install-schemas 2>&1) || true
10091if selftest_out=$("$BIN" --selftest 2>&1); then
101− [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
92+ echo "Diagonal's host is ready."
10293else
10394 echo "Diagonal's host is installed, but its self-test found a problem:"
10495 echo "$schemas_out"
src/background/engine.ts+5−101
@@ -1,9 +1,8 @@
11import type { GroupColor } from "../shared/colors";
22import { isColor } from "../shared/colors";
33import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4−import { titleKey } from "../shared/label";
54import type { Settings } from "./settings";
6−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
5+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
76
87/**
98 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -62,10 +61,6 @@ export interface Ctx {
6261export const DISSOLVE_DELAY_MS = 1500;
6362export const OWN_CREATE_WINDOW_MS = 2000;
6463export const OWN_WRITE_WINDOW_MS = 60_000;
65−/** A group moved to another window is removed and recreated with the same id within milliseconds. */
66−export const REVIVE_WINDOW_MS = 10_000;
67−export const REMOVED_KEEP_MS = 24 * 3_600_000;
68−export const REMOVED_CAP = 20;
6964
7065/** The pure form: never touches `prev`. */
7166export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -90,16 +85,8 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
9085const handlers: Handlers = {
9186 tabCreated(s, { tab, opener }, ctx, out) {
9287 if (tab.incognito) return;
93− const known = !!s.tabs[tab.id];
9488 const rec = upsertTab(s, tab, ctx.now);
95− if (rec.groupId !== -1) {
96− // Born inside a group with no page that led to it ("New tab in group"): the user put it there.
97− // A link opened from a grouped tab has an opener and stays Diagonal's to check.
98− const g = s.groups[rec.groupId];
99− const newPage = tab.openerTabId === undefined || isInternalUrl(tab.pendingUrl || tab.url);
100− if (!known && newPage && g && !revivedLately(g, ctx.now)) rec.handPlaced = true;
101− dirty(s, rec.groupId, ctx, out);
102− }
89+ if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
10390 const before = out.length;
10491 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
10592 if (out.length === before) loose(rec, ctx, out);
@@ -114,23 +101,13 @@ const handlers: Handlers = {
114101 else loose(rec, ctx, out);
115102 return;
116103 }
117− const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
104+ const titleChanged = before.title !== rec.title;
118105 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
119106 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
120107 if (before.groupId !== rec.groupId) {
121108 left(s, before.groupId, ctx, out);
122− if (inTransit(s, before.groupId, rec.groupId, ctx.now)) {
123− // Chromium moves a group to another window by removing it, ungrouping its tabs and grouping
124− // them again under the same id: nobody chose anything, so the tab keeps what it had.
125− delete s.ownAdds[rec.id];
126− if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
127− else loose(rec, ctx, out);
128− return;
129− }
130109 rec.fitPending = undefined;
131− if (s.groups[before.groupId]?.origin === "tidy") rec.parkedAt = undefined; // out of Parked
132110 if (rec.groupId !== -1) {
133− rec.keepLoose = undefined; // in a group now: there is no loose choice left to protect
134111 // Into a group: ours if the worker put it there, otherwise the user did and it stays.
135112 if (s.ownAdds[rec.id] !== undefined) {
136113 delete s.ownAdds[rec.id];
@@ -158,9 +135,7 @@ const handlers: Handlers = {
158135 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
159136 return;
160137 }
161− // Only a tab that had finished a page moves on: a new tab's first load redirecting (http to https,
162− // a link shortener) is still its first page.
163− if (pathChanged && before.status === "complete" && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
138+ if (pathChanged && fitCheckable(s, rec, ctx.settings)) rec.fitPending = true;
164139 if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
165140 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
166141 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
@@ -185,7 +160,6 @@ const handlers: Handlers = {
185160 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
186161 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
187162 rec.parkedFrom = undefined;
188− rec.parkedAt = undefined;
189163 out.push({ type: "unpark", tabId, groupId: rec.groupId });
190164 }
191165 },
@@ -200,15 +174,6 @@ const handlers: Handlers = {
200174 registerGroup(s, group, ctx, out);
201175 return;
202176 }
203− // A group Chromium restores (a reopened window, the session after a restart) is created untitled
204− // and gets its title a moment later: that title and colour can say it is one of Diagonal's back.
205− if (rec.registeredAt !== undefined && ctx.now - rec.registeredAt <= REVIVE_WINDOW_MS) {
206− const back = buried(s, group.title ?? "", group.color);
207− if (back) {
208− revive(s, back, group, ctx);
209− return;
210− }
211− }
212177 rec.windowId = group.windowId;
213178 const own = s.ownWrites[group.id];
214179 const title = group.title ?? "";
@@ -236,11 +201,9 @@ const handlers: Handlers = {
236201 }
237202 },
238203
239− groupRemoved(s, { groupId }, ctx) {
240− const rec = s.groups[groupId];
204+ groupRemoved(s, { groupId }) {
241205 delete s.groups[groupId];
242206 delete s.ownWrites[groupId];
243− if (rec) bury(s, rec, ctx.now);
244207 },
245208
246209 meta(s, { tabId, description }, ctx, out) {
@@ -343,64 +306,16 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
343306 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
344307 return;
345308 }
346− // Moved to another window (same id, just removed) or reopened (same title and colour): the same group.
347− const moved = s.removedGroups[group.id];
348− const back = moved && ctx.now - moved.removedAt <= REVIVE_WINDOW_MS ? moved : buried(s, group.title ?? "", group.color);
349− if (back) {
350− revive(s, back, group, ctx);
351− return;
352− }
353309 const titled = !!group.title;
354310 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
355311 managed: ctx.settings.nameUserGroups,
356312 userNamed: titled,
357313 stripTitle: group.title ?? "",
358− registeredAt: ctx.now,
359314 });
360315 s.groups[group.id] = rec;
361316 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
362317}
363318
364−/** Keep a removed group's record for a while, so the group is still Diagonal's if Chromium brings it back. */
365−export function bury(s: State, rec: GroupRecord, now: number): void {
366− s.removedGroups[rec.id] = { ...rec, removedAt: now };
367− pruneRemoved(s, now);
368−}
369−
370−/** The most recently removed group with this title and colour, if any. */
371−function buried(s: State, title: string, color: string): RemovedGroup | undefined {
372− if (!title) return undefined;
373− let best: RemovedGroup | undefined;
374− for (const g of Object.values(s.removedGroups)) {
375− if ((g.stripTitle ?? "") === title && g.color === color && (!best || g.removedAt > best.removedAt)) best = g;
376− }
377− return best;
378−}
379−
380−function revive(s: State, back: RemovedGroup, group: GroupSnapshot, ctx: Ctx): void {
381− const { removedAt: _, registeredAt: __, ...rec } = back;
382− delete s.removedGroups[back.id];
383− delete s.groups[group.id];
384− s.groups[group.id] = {
385− ...rec,
386− id: group.id,
387− windowId: group.windowId,
388− color: isColor(group.color) ? group.color : rec.color,
389− // An untitled restore gets its title next: keep the known one so that update is not read as an edit.
390− stripTitle: group.title || rec.stripTitle,
391− revivedAt: ctx.now,
392− };
393−}
394−
395−const revivedLately = (g: GroupRecord | undefined, now: number): boolean => !!g?.revivedAt && now - g.revivedAt <= REVIVE_WINDOW_MS;
396−
397−/** Leaving a group Chromium just removed, or joining one it is bringing back: a move, not a choice. */
398−function inTransit(s: State, from: number, to: number, now: number): boolean {
399− const gone = (id: number) => !s.groups[id] && !!s.removedGroups[id];
400− if (to === -1) return from !== -1 && gone(from);
401− return from === -1 && (gone(to) || revivedLately(s.groups[to], now));
402−}
403−
404319function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
405320 const before = s.tabs[tab.id];
406321 const url = tab.url || tab.pendingUrl || "";
@@ -421,10 +336,7 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
421336 // A description belongs to a page: keep it only while the tab stays on that page.
422337 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
423338 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
424− if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
425− if (before?.parkedAt) rec.parkedAt = before.parkedAt;
426339 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
427− if (before?.organizeRefused) rec.organizeRefused = before.organizeRefused;
428340 if (before?.handPlaced) rec.handPlaced = true;
429341 if (before?.fitPending) rec.fitPending = true;
430342 s.tabs[tab.id] = rec;
@@ -450,12 +362,4 @@ function prune(s: State, now: number): void {
450362 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
451363 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
452364 for (const [id, at] of Object.entries(s.ownAdds)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownAdds[+id];
453− pruneRemoved(s, now);
454−}
455−
456−function pruneRemoved(s: State, now: number): void {
457− const removed = Object.values(s.removedGroups).sort((a, b) => b.removedAt - a.removedAt);
458− removed.forEach((g, i) => {
459− if (i >= REMOVED_CAP || now - g.removedAt > REMOVED_KEEP_MS) delete s.removedGroups[g.id];
460− });
461365}
src/background/host.ts−3
@@ -102,9 +102,6 @@ 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−
108105/** Errors that mean "fix the setup", not "try this group again later". */
109106export const SETUP_ERRORS = new Set<HostErrorCode>([
110107 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+36−121
@@ -1,14 +1,14 @@
11import { stripTitle } from "../shared/label";
2−import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
3−import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, REQUEST_ERRORS, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
2+import { applyEvent, 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";
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";
77import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
88import { withDefaults, type Settings } from "./settings";
9−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
1010import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
11−import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
1212
1313/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
1414
@@ -20,8 +20,6 @@ const ALARM_HOST = "host-retry";
2020const ALARM_AUTO = "auto-organize";
2121const ALARM_UPDATE = "self-update";
2222const HOST_RETRY_MS = 600_000;
23−/** Records made after this were made by this worker, so after a restart they describe the new session. */
24−const WORKER_STARTED_AT = Date.now();
2523
2624// ----- state cache ------------------------------------------------------------------------------
2725
@@ -93,11 +91,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9391 h.lastOkAt = Date.now();
9492 h.consecutiveFailures = 0;
9593 h.consecutiveTimeouts = 0;
96− if (op !== "ping") {
97− h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
98− // A model answer (a manual Organize or Name now during a pause) proves the helper works again.
99− if (h.pausedUntil) recovered();
100− }
94+ h.rateLimitStep = 0;
10195 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
10296 } else {
10397 recordHostError(reply.error);
@@ -111,8 +105,6 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
111105}
112106
113107function recordHostError(e: HostError): void {
114− // About one request's tabs, not the host's health: the caller retries or sets those tabs aside.
115− if (REQUEST_ERRORS.has(e.code)) return;
116108 const h = state.host;
117109 const now = Date.now();
118110 h.lastError = { ...e, at: now };
@@ -144,7 +136,11 @@ async function ping(): Promise<HostReply<PingResult>> {
144136 } else if (!reply.result.schemasOk) {
145137 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
146138 } else {
147− recovered();
139+ h.pausedUntil = undefined;
140+ h.lastError = undefined;
141+ chrome.alarms.clear(ALARM_HOST);
142+ void naming.sweepDirty();
143+ void autoOrganizer.sweep();
148144 }
149145 }
150146 commit();
@@ -152,16 +148,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152148 return reply;
153149}
154150
155−/** The helper answers again: lift the pause and pick up what waited for it. */
156−function recovered(): void {
157− const h = state.host;
158− h.pausedUntil = undefined;
159− h.lastError = undefined;
160− chrome.alarms.clear(ALARM_HOST);
161− void naming.sweepDirty();
162− void autoOrganizer.sweep();
163−}
164−
165151// ----- badge -----------------------------------------------------------------------------------
166152
167153function busy(on: boolean): void {
@@ -173,6 +159,7 @@ function hostUnhealthy(): boolean {
173159 const h = state.host;
174160 const now = Date.now();
175161 if (h.pausedUntil && h.pausedUntil > now) return true;
162+ if (h.consecutiveFailures >= 3) return true;
176163 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
177164}
178165
@@ -359,24 +346,16 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
359346 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
360347 * (groups), which keeps managed groups managed after the browser restarts.
361348 */
362−async function reconcile(reason: "startup" | "installed"): Promise<void> {
349+async function reconcile(): Promise<void> {
363350 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
364351 const now = Date.now();
365− const liveTabIds = new Set(tabs.map((t) => t.id));
366− // Records of tabs that are gone, by address: after a restart every tab has a new id.
367− const byUrl = new Map<string, TabRecord>(Object.values(state.tabs).filter((t) => !liveTabIds.has(t.id)).map((t) => [t.url, t]));
352+ const oldTabs = Object.values(state.tabs);
353+ const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
368354 const nextTabs: Record<number, TabRecord> = {};
369355 for (const t of tabs) {
370356 if (t.incognito || t.id === undefined) continue;
371357 const url = t.url || t.pendingUrl || "";
372− const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
373− // An event handled before this ran may have recorded a restored tab as new: its old record knows more.
374− const old = byUrl.get(url);
375− const prev = old && (!same || same.createdAt >= WORKER_STARTED_AT) ? old : same;
376− // Chromium stamps a restored tab it has not shown yet with the restore time, so after a restart the
377− // stored time is the truer one for every tab but the one on screen.
378− const lastSeen =
379− reason === "startup" && prev && !t.active ? prev.lastActivatedAt : Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0);
358+ const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
380359 nextTabs[t.id] = {
381360 id: t.id,
382361 windowId: t.windowId,
@@ -389,51 +368,25 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
389368 openerTabId: t.openerTabId,
390369 description: prev?.description,
391370 parkedFrom: prev?.parkedFrom,
392− parkedAt: prev?.parkedAt,
393371 keepLoose: prev?.keepLoose,
394372 organizedKey: prev?.organizedKey,
395− organizeRefused: prev?.organizeRefused,
396373 handPlaced: prev?.handPlaced,
397− fitPending: prev?.fitPending,
398374 createdAt: prev?.createdAt ?? now,
399− lastActivatedAt: lastSeen || now,
375+ lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
400376 };
401377 }
402− const liveGroupIds = new Set(groups.map((g) => g.id));
403378 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
404− // Made by this worker for a group it did not know: after a restart, that is a restored group whose
405− // events were handled before this ran, and the record from before the restart is the one to keep.
406− const fresh = (g: GroupRecord) => g.origin === "user" && (g.registeredAt ?? 0) >= WORKER_STARTED_AT;
407− const lookalike = (title: string, color: string): GroupRecord | RemovedGroup | undefined => {
408− if (title === "") return undefined;
409− const alike = (o: GroupRecord) => (o.stripTitle ?? "") === title && o.color === color;
410− return (
411− [...unmatched.values()].find((o) => alike(o) && !liveGroupIds.has(o.id) && !fresh(o)) ??
412− Object.values(state.removedGroups)
413− .filter(alike)
414− .sort((a, b) => b.removedAt - a.removedAt)[0]
415− );
416− };
417379 const nextGroups: Record<number, GroupRecord> = {};
418380 for (const g of groups) {
419381 const title = g.title ?? "";
420− let prev: GroupRecord | undefined = unmatched.get(g.id);
421− if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
422− const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
423− if (before) prev = before;
424− let rec: GroupRecord;
425− if (prev) {
426− unmatched.delete(prev.id);
427− delete state.removedGroups[prev.id];
428− const { removedAt: _, ...kept } = prev as RemovedGroup;
429− rec = { ...kept, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title };
430− if (before) rec.registeredAt = undefined;
431− } else {
432− // Untitled so far may still mean restored: its title can arrive next and match (engine.ts groupUpdated).
433− rec = newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title, registeredAt: now });
382+ let prev = unmatched.get(g.id);
383+ if (!prev || (prev.stripTitle ?? "") !== title) {
384+ prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
434385 }
435− // Earlier versions turned Parked into a plain group after a restart: give it back its job.
436− if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
386+ if (prev) unmatched.delete(prev.id);
387+ const rec: GroupRecord = prev
388+ ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
389+ : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
437390 if (rec.managed) {
438391 rec.dirty = true;
439392 rec.dirtyAt = 0;
@@ -441,17 +394,14 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
441394 }
442395 nextGroups[g.id] = rec;
443396 }
444− // Groups not seen now (a closed window, a quit that did not save) may still come back: keep them a day.
445− for (const old of unmatched.values()) if (!liveGroupIds.has(old.id) && !fresh(old)) bury(state, old, now);
446397 state.tabs = nextTabs;
447398 state.groups = nextGroups;
448399 state.pendingCreates = [];
449400 state.ownWrites = {};
450401 state.ownUngroups = {};
451402 state.ownAdds = {};
452− // Undo lists name tabs by id, and after a restart none of those ids exist.
453− if (reason === "startup" || (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS)) state.lastSweep = undefined;
454− if (reason === "startup" || (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS)) state.lastOrganize = undefined;
403+ if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
404+ if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
455405 commit();
456406}
457407
@@ -464,13 +414,9 @@ async function injectBacklog(): Promise<void> {
464414 }
465415}
466416
467−let reconciled: Promise<void> | undefined;
468−
469417async function boot(reason: "startup" | "installed"): Promise<void> {
470− // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
471− // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
472− reconciled = serial("reconcile", () => reconcile(reason));
473− await reconciled;
418+ await ready;
419+ await serial("reconcile", reconcile);
474420 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
475421 if (reason === "installed") void injectBacklog();
476422 await ping();
@@ -512,29 +458,20 @@ if (GROUPS_SUPPORTED) {
512458
513459 chrome.tabs.onReplaced.addListener((added, removed) =>
514460 void serial("tabs.onReplaced", async () => {
515− // Chromium swapped in a new tab (a prerendered page, a discarded tab coming back): it is the same
516− // tab to the user, so it keeps what Diagonal knew about it, including choices the user made.
517461 const rec = state.tabs[removed];
518462 delete state.tabs[removed];
519463 const t = await tabById(added);
520464 if (t) {
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522465 await feed({ type: "tabUpdated", tab: snapTab(t) });
466+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
523467 }
524468 }),
525469 );
526470
527471 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
528472
529− // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
530− // untitled and then titled, and replaying the stale "untitled" would read as you clearing the title.
531− const refetchGroup = (where: string, type: "groupCreated" | "groupUpdated") => (g: chrome.tabGroups.TabGroup) =>
532− void serial(where, async () => {
533− const now = await chrome.tabGroups.get(g.id).catch(() => undefined);
534− if (now) await feed({ type, group: snapGroup(now) });
535− });
536− chrome.tabGroups.onCreated.addListener(refetchGroup("tabGroups.onCreated", "groupCreated"));
537− chrome.tabGroups.onUpdated.addListener(refetchGroup("tabGroups.onUpdated", "groupUpdated"));
473+ chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
474+ chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
538475 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
539476}
540477
@@ -563,10 +500,7 @@ chrome.alarms.onAlarm.addListener((alarm) => {
563500 await ready;
564501 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
565502 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
566− else if (alarm.name === ALARM_TIDY) {
567− await reconciled; // an overdue sweep right after a restart waits for the new tab and group ids
568− await runSweep(rt).catch((e) => logError("tidy", e));
569− }
503+ else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
570504 else if (alarm.name === ALARM_HOST) {
571505 const r = await ping();
572506 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -580,46 +514,27 @@ chrome.storage.onChanged.addListener((changes, area) => {
580514 await ready;
581515 const before = settings;
582516 settings = withDefaults(changes.settings.newValue);
583− if (before.emoji !== settings.emoji) await rewriteTitles(before);
584− if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
517+ if (before.emoji !== settings.emoji) await rewriteTitles();
585518 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
586519 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
587520 refreshBadge();
588521 })();
589522});
590523
591−/** "Name groups I make too" applies to the groups you already made, not only to new ones. */
592−function applyNameUserGroups(): void {
593− for (const g of Object.values(state.groups)) {
594− if (g.origin !== "user" || g.userNamed) continue;
595− g.managed = settings.nameUserGroups;
596− if (g.managed) {
597− markDirty(g, Date.now());
598− naming.touch(g.id);
599− }
600− }
601− commit();
602−}
603−
604524/** Emoji switched on or off: rewrite the titles the extension wrote. */
605−async function rewriteTitles(before: Settings): Promise<void> {
525+async function rewriteTitles(): Promise<void> {
606526 for (const g of Object.values(state.groups)) {
607− // Parked is always "user named", so its title is compared instead: one you typed stays.
608− if (g.origin === "tidy") {
609− if ((g.stripTitle ?? "") === parkedTitle(before)) await updateGroup(rt, g.id, { title: parkedTitle(settings) });
610− } else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
527+ if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
528+ else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
611529 }
612530}
613531
614532async function nameGroupNow(groupId: number): Promise<void> {
615533 const g = state.groups[groupId];
616534 if (!g) return;
617− // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
618− // answer goes on the strip even when it is close to the label it gave before.
535+ // An explicit request overrides "don't name" and a hand-typed title for this group.
619536 g.managed = true;
620537 g.userNamed = false;
621− g.title = undefined;
622− g.lastNamedAt = undefined;
623538 commit();
624539 await naming.nameNow(groupId);
625540}
src/background/naming.ts+25−77
@@ -1,7 +1,6 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4−import { pathKey } from "./engine";
54import type { HostOpts, HostReply } from "./host";
65import { SETUP_ERRORS } from "./host";
76import type { Settings } from "./settings";
@@ -61,7 +60,7 @@ export const settled = (t: { status?: string }): boolean => t.status !== "loadin
6160
6261/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
6362export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
64− sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
63+ sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
6564
6665export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
6766
@@ -114,9 +113,8 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
114113 if (g.userNamed) return false;
115114 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
116115 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.
118− const before = new Set((g.memberUrls ?? []).map(pathKey));
119− const after = new Set(urls.map(pathKey));
116+ const before = new Set(g.memberUrls ?? []);
117+ const after = new Set(urls);
120118 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
121119 return changed; // a paraphrase with the same members keeps the old title
122120}
@@ -216,32 +214,18 @@ export class Naming {
216214 }
217215 const now = this.d.now();
218216 if (!force) {
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;
217+ if (g.nextAttemptAt && g.nextAttemptAt > now) return;
221218 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
222219 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
223220 }
224221 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
225222 if (!members) return;
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);
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;
238226 this.d.commit();
239227 return;
240228 }
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− }
245229 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
246230 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
247231 void this.pump();
@@ -277,7 +261,7 @@ export class Naming {
277261 if (!members || members.length < 2 || !members.every(settled)) return;
278262 const hash = membersHash(members);
279263 if (!force && hash === g.membersHash) {
280− settle(g);
264+ g.dirty = false;
281265 this.d.commit();
282266 return;
283267 }
@@ -312,10 +296,9 @@ export class Naming {
312296 }
313297 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314298 retried.output = true;
315− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
316299 continue;
317300 }
318− this.fail(g, e, hash);
301+ this.fail(g, e);
319302 return;
320303 }
321304
@@ -324,10 +307,9 @@ export class Naming {
324307 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
325308 if (!retried.output) {
326309 retried.output = true;
327− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
328310 continue;
329311 }
330− this.fail(g, e, hash);
312+ this.fail(g, e);
331313 return;
332314 }
333315 const emoji = safeEmoji(reply.result.emoji);
@@ -342,15 +324,12 @@ export class Naming {
342324 finalLabel = suffixLabel(label, siblings);
343325 }
344326 this.d.hostOk();
345− const written = await this.apply(g, finalLabel, emoji, members, hash);
327+ await this.apply(g, finalLabel, emoji, members, hash);
346328 g = this.d.state().groups[groupId];
347− if (!g) return;
348− if (!written) {
349− // The strip refused the title (a drag in progress, the window closing): try again shortly.
350− g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
351− this.d.scheduleFallback(g.nextAttemptAt);
352− } else if (g.dirtySeq === seq) g.dirty = false;
353− else this.touch(groupId); // dirtied while in flight: go again with the new membership
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+ }
354333 return;
355334 }
356335 } finally {
@@ -361,20 +340,16 @@ export class Naming {
361340 }
362341 }
363342
364− /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
365− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
343+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
366344 const settings = this.d.settings();
367345 const urls = memberUrls(members);
368− if (g.userNamed) return true; // renamed by hand while the model was thinking
346+ if (g.userNamed) return; // renamed by hand while the model was thinking
369347 if (shouldApply(g, label, urls)) {
370348 const title = stripTitle(label, emoji, settings.emoji);
371349 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
372350 this.d.commit();
373351 const ok = await this.d.writeTitle(g.id, title);
374− if (!ok) {
375− delete this.d.state().ownWrites[g.id];
376− return false;
377− }
352+ if (!ok) return;
378353 g.title = label;
379354 g.emoji = emoji;
380355 g.stripTitle = title;
@@ -382,24 +357,21 @@ export class Naming {
382357 g.lastNamedAt = this.d.now();
383358 g.membersHash = hash;
384359 g.memberUrls = urls;
385− clearFailures(g);
386− return true;
360+ g.nameAttempts = 0;
361+ g.firstFailureAt = undefined;
362+ g.nextAttemptAt = undefined;
387363 }
388364
389− private fail(g: GroupRecord, e: HostError, hash: string): void {
365+ private fail(g: GroupRecord, e: HostError): void {
390366 const now = this.d.now();
391367 this.d.hostFailed(e);
392368 if (e.code === "BAD_REQUEST") {
393369 g.dirty = false; // a bug on our side: log and drop
394370 return;
395371 }
396− // Setup errors pause every model call until the host is fixed, and its recovery picks this group up
397− // again: they say nothing about this group, so they don't use up its retries.
398− if (SETUP_ERRORS.has(e.code)) return;
399372 if (e.code === "GUARDRAIL") {
400373 g.nameAttempts = MAX_ATTEMPTS;
401374 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402− g.refusedHash = hash;
403375 this.d.scheduleFallback(g.nextAttemptAt);
404376 return;
405377 }
@@ -409,35 +381,11 @@ export class Naming {
409381 g.dirty = false; // retried for a day: keep the provisional title
410382 return;
411383 }
412− g.nextAttemptAt = now + backoffFor(g.nameAttempts);
384+ const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
385+ g.nextAttemptAt = now + wait;
413386 this.d.scheduleFallback(g.nextAttemptAt);
414387 }
415388}
416389
417−/** The retry after an unusable title lists that title as not allowed, so the model doesn't give it again. */
418−function withRejected(list: string[] | undefined, raw: string | undefined): string[] | undefined {
419− let title: unknown;
420− try {
421− title = raw ? JSON.parse(raw)?.title : undefined;
422− } catch {
423− return list;
424− }
425− return typeof title === "string" && title.trim() ? [...(list ?? []), title.trim().slice(0, 60)] : list;
426−}
427−
428−/** The retry bookkeeping of earlier failures, which no longer applies. */
429−function clearFailures(g: GroupRecord): void {
430− g.nameAttempts = 0;
431− g.firstFailureAt = undefined;
432− g.nextAttemptAt = undefined;
433− g.refusedHash = undefined;
434−}
435−
436−/** The members are the ones last named: nothing to do, and nothing left to retry. */
437−function settle(g: GroupRecord): void {
438− g.dirty = false;
439− clearFailures(g);
440−}
441−
442390export const eligible = (g: GroupRecord, settings: Settings): boolean =>
443391 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+107−194
@@ -1,11 +1,10 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle } 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 type { HostReply } from "./host";
8−import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
7+import { markDirty, type State } from "./state";
98
109/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1110
@@ -27,8 +26,6 @@ export interface ExistingGroup {
2726export interface OrganizePayload {
2827 items: NameItem[];
2928 existingGroups: ExistingGroup[];
30− /** Every group label in the window: a new group the host names must not repeat one. */
31− siblingTitles?: string[];
3229 allowNew: boolean;
3330 maxGroups: number;
3431 minGroupSize: number;
@@ -154,198 +151,88 @@ export interface OrganizeReport {
154151 error?: string;
155152}
156153
157−/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
154+/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
158155export 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));
163156 const groups = Object.values(state.groups)
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);
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);
169160 const list = groups.map((g, i) => ({
170161 g: i,
171162 title: labelOf(g.stripTitle || g.title),
172− samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
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)),
173167 }));
174168 return { ids: groups.map((g) => g.id), list };
175169}
176170
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− }
256− 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−
306171export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307− const s0 = rt.state();
172+ const settings = rt.settings();
308173 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
309174 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
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) {
175+ if (tabs.length < 2) {
313176 report.left = tabs.length;
314177 return report;
315178 }
316− const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
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);
317191 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318192 rt.refreshBadge();
319193 try {
320− for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
321− await sortPart(rt, batch, run);
322− if (run.stopped) break;
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;
323224 }
324225 } finally {
325226 const s = rt.state();
326227 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
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 };
228+ report.groups = createdIds.length;
229+ if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
332230 rt.commit();
333231 rt.refreshBadge();
334232 }
335233 return report;
336234}
337235
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. */
349236export async function applyPlan(
350237 rt: Runtime,
351238 plan: Plan,
@@ -353,15 +240,11 @@ export async function applyPlan(
353240 members: Member[],
354241 previous: Record<number, number>,
355242 createdIds: number[],
356−): Promise<Set<number>> {
243+): Promise<void> {
357244 const settings = rt.settings();
358245 const byId = new Map(members.map((m) => [m.id, m]));
359− const failed = new Set<number>();
360246 for (const j of plan.joins) {
361− if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
362− for (const id of j.tabIds) failed.add(id);
363− continue;
364− }
247+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
365248 for (const id of j.tabIds) previous[id] = -1;
366249 const g = rt.state().groups[j.groupId];
367250 if (g) {
@@ -382,10 +265,7 @@ export async function applyPlan(
382265 lastNamedAt: named ? rt.now() : undefined,
383266 dirty: false,
384267 });
385− if (groupId === undefined) {
386− for (const id of c.tabIds) failed.add(id);
387− continue;
388− }
268+ if (groupId === undefined) continue;
389269 createdIds.push(groupId);
390270 for (const id of c.tabIds) previous[id] = -1;
391271 const g = rt.state().groups[groupId];
@@ -394,7 +274,6 @@ export async function applyPlan(
394274 rt.naming.touch(groupId);
395275 }
396276 }
397− return failed;
398277}
399278
400279/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -422,7 +301,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
422301}
423302
424303/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
425−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
304+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
426305
427306export const AUTO_RETRY_MS = 300_000;
428307export const AUTO_CONTINUE_MS = 1_500;
@@ -500,25 +379,59 @@ export class AutoOrganizer {
500379 const s = rt.state();
501380 // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
502381 // their title and address, so they're sorted like loaded ones.
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− });
382+ const loose = tabs.filter((t) => organizable(t) && settled(t) && !s.tabs[t.id!]?.keepLoose);
507383 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
508384 if (!fresh.length) return;
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));
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);
511397 const batch = makeBatches(ordered)[0];
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);
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);
517419 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
518420 return;
519421 }
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);
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);
522435 }
523436}
524437
src/background/runtime.ts+2−13
@@ -67,25 +67,20 @@ export async function createManagedGroup(
6767 extra: Partial<GroupRecord> = {},
6868): Promise<number | undefined> {
6969 const s = rt.state();
70− const pending = { windowId, origin, at: rt.now() };
71− s.pendingCreates.push(pending);
70+ s.pendingCreates.push({ windowId, origin, at: rt.now() });
7271 for (const id of tabIds) s.ownAdds[id] = rt.now();
7372 let groupId: number;
7473 try {
7574 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
7675 } catch (e) {
7776 rt.log("tabs.group failed", e);
78− forgetOwnAdds(rt.state(), tabIds);
79− const live = rt.state();
80− live.pendingCreates = live.pendingCreates.filter((p) => p !== pending);
8177 return undefined;
8278 }
8379 const live = rt.state();
8480 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
8581 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
8682 live.groups[groupId] = rec;
87− // Tab records move when tabs.onUpdated reports the new groupId: that event also tells the group
88− // each tab left that it lost one, and uses up the ownAdds marker.
83+ for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
8984 await updateGroup(rt, groupId, props);
9085 rt.commit();
9186 return groupId;
@@ -99,16 +94,10 @@ export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number)
9994 return true;
10095 } catch (e) {
10196 rt.log("tabs.group into existing failed", e);
102− forgetOwnAdds(rt.state(), tabIds);
10397 return false;
10498 }
10599}
106100
107−/** A grouping call failed: nothing moved, so a later move of these tabs is not the worker's. */
108−function forgetOwnAdds(s: State, tabIds: number[]): void {
109− for (const id of tabIds) delete s.ownAdds[id];
110−}
111−
112101export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
113102 if (!tabIds.length) return;
114103 try {
src/background/state.ts−18
@@ -18,10 +18,8 @@ export interface TabRecord {
1818 createdAt: number;
1919 lastActivatedAt: number; // epoch ms
2020 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
21− parkedAt?: number; // when the tidy sweep parked it: the archive clock starts here
2221 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2322 organizedKey?: string; // page + title when auto-organize last considered this tab
24− organizeRefused?: string; // page + title the model would not sort even alone: not sent again until it changes
2523 handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
2624 fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
2725}
@@ -47,15 +45,8 @@ export interface GroupRecord {
4745 nameAttempts: number;
4846 firstFailureAt?: number;
4947 nextAttemptAt?: number;
50− refusedHash?: string; // the members Apple's filter refused to name: not asked again until they change
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
52− revivedAt?: number; // came back after Chromium removed it (window move, reopen, restart)
53− restored?: boolean; // "Restored": the archive brought back, not a topic other tabs should join
5448}
5549
56−/** A group Chromium removed, kept for a day: it may come back (moved to another window, reopened, restored). */
57−export type RemovedGroup = GroupRecord & { removedAt: number };
58−
5950export interface ArchivedTab {
6051 url: string;
6152 title: string;
@@ -116,11 +107,6 @@ export interface SweepMove {
116107 groupId: number;
117108 index: number;
118109 windowId: number;
119− /** The group the tab was in, so undo can make it again if parking emptied it. */
120− group?: Pick<
121− GroupRecord,
122− "origin" | "managed" | "userNamed" | "title" | "emoji" | "stripTitle" | "color" | "colorLocked" | "keep" | "membersHash" | "memberUrls" | "lastNamedAt"
123− >;
124110}
125111
126112export interface State {
@@ -138,8 +124,6 @@ export interface State {
138124 ownUngroups: Record<number, number>;
139125 /** Tabs the worker itself is adding to a group: joining it is not the user's choice. */
140126 ownAdds: Record<number, number>;
141− /** Groups Chromium removed lately, by their last id. */
142− removedGroups: Record<number, RemovedGroup>;
143127 lastSweep?: { at: number; moves: SweepMove[] };
144128 lastOrganize?: { at: number; previous: Record<number, number> };
145129 tidyCandidates?: number;
@@ -155,7 +139,6 @@ export const emptyState = (): State => ({
155139 pendingCreates: [],
156140 ownUngroups: {},
157141 ownAdds: {},
158− removedGroups: {},
159142});
160143
161144/** Accept whatever was stored and bring it to the current shape. */
@@ -177,7 +160,6 @@ export function migrate(raw: unknown): State {
177160 pendingCreates: r.pendingCreates ?? [],
178161 ownUngroups: r.ownUngroups ?? {},
179162 ownAdds: r.ownAdds ?? {},
180− removedGroups: r.removedGroups ?? {},
181163 };
182164}
183165
src/background/tidy.ts+17−129
@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
33import { isInternalUrl } from "../shared/url";
44import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
55import type { Settings } from "./settings";
6−import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
6+import type { ArchivedTab, State, SweepMove } from "./state";
77
88/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
99
@@ -23,22 +23,11 @@ export interface TidyTab {
2323 audible?: boolean;
2424 incognito?: boolean;
2525 discarded?: boolean;
26− autoDiscardable?: boolean;
2726 lastAccessed?: number;
2827}
2928
30−/**
31− * The engine's record wins over Chromium's lastAccessed: after a restart Chromium stamps restored tabs
32− * nobody has looked at yet with the restore time, which would keep them from ever going stale.
33− */
3429export const idleMs = (tab: TidyTab, state: State, now: number): number =>
35− now - (state.tabs[tab.id]?.lastActivatedAt ?? tab.lastAccessed ?? now);
36−
37−/** How long a tab has sat in Parked. Tabs found there with no parking time start their clock now. */
38−export const parkedMs = (tab: TidyTab, state: State, now: number): number => {
39− const at = state.tabs[tab.id]?.parkedAt;
40− return at === undefined ? 0 : now - at;
41−};
30+ now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
4231
4332/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
4433export function excluded(url: string, patterns: string[]): boolean {
@@ -66,8 +55,7 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
6655 if (tab.groupId !== -1) {
6756 const g = state.groups[tab.groupId];
6857 if (g?.keep) return false;
69− // Groups you made or renamed are yours, like Diagonal's other rules treat them.
70− if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
58+ if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
7159 }
7260 if (excluded(tab.url, settings.tidyExclusions)) return false;
7361 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -78,18 +66,10 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
7866 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
7967}
8068
81−/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
8269export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
8370 if (!settings.archiveAfterHours) return [];
8471 const parking = parkingGroupIds(state);
85− return tabs.filter(
86− (t) =>
87− parking.has(t.groupId) &&
88− !t.active &&
89− !t.audible &&
90− !(t.url && excluded(t.url, settings.tidyExclusions)) &&
91− parkedMs(t, state, now) >= settings.archiveAfterHours * HOUR,
92− );
72+ return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
9373}
9474
9575/** Prepend entries newest first and drop the oldest past the cap. */
@@ -99,15 +79,6 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
9979
10080export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
10181
102−/** A grey group titled like Parked: Diagonal's Parked group, even where an older version lost track of it. */
103−export const looksParked = (g: { title?: string; color: string }, settings: Settings): boolean =>
104− g.color === "grey" && (g.title ?? "") === parkedTitle(settings);
105−
106−/** Make a group Diagonal's Parked group again. */
107−export function adoptParked(g: GroupRecord, settings: Settings): void {
108− Object.assign(g, { origin: "tidy", managed: true, userNamed: true, stripTitle: parkedTitle(settings) });
109−}
110−
11182// ---------------------------------------------------------------------------------------------
11283
11384const liveTabs = async (): Promise<TidyTab[]> =>
@@ -124,7 +95,6 @@ const liveTabs = async (): Promise<TidyTab[]> =>
12495 audible: t.audible,
12596 incognito: t.incognito,
12697 discarded: t.discarded,
127− autoDiscardable: t.autoDiscardable,
12898 lastAccessed: t.lastAccessed,
12999 }));
130100
@@ -134,20 +104,11 @@ export interface SweepReport {
134104 archived: number;
135105}
136106
137−let sweeps: Promise<unknown> = Promise.resolve();
138−
139107/**
140108 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
141− * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142− * same tabs twice.
109+ * consent, still respects eligibility).
143110 */
144−export function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
145− const next = sweeps.then(() => sweep(rt, manual));
146− sweeps = next.catch(() => undefined);
147− return next;
148−}
149−
150−async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
111+export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
151112 const settings = rt.settings();
152113 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
153114 if (settings.tidyMode === "off" && !manual) {
@@ -157,7 +118,6 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
157118 }
158119 const now = rt.now();
159120 let tabs = await liveTabs();
160− startParkedClocks(rt.state(), tabs, now);
161121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
162122 report.candidates = candidates.length;
163123 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -173,52 +133,8 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
173133 return report;
174134}
175135
176−/** Tabs already in Parked with no parking time (from before it was recorded) start their archive clock now. */
177−function startParkedClocks(s: State, tabs: TidyTab[], now: number): void {
178− const parking = parkingGroupIds(s);
179− for (const t of tabs) {
180− const rec = s.tabs[t.id];
181− if (rec && parking.has(t.groupId) && rec.parkedAt === undefined) rec.parkedAt = now;
182− }
183−}
184−
185−/** What undo needs to make a group again if parking emptied it, named as it was. */
186−const groupShape = (g: GroupRecord): SweepMove["group"] => ({
187− origin: g.origin,
188− managed: g.managed,
189− userNamed: g.userNamed,
190− title: g.title,
191− emoji: g.emoji,
192− stripTitle: g.stripTitle,
193− color: g.color,
194− colorLocked: g.colorLocked,
195− keep: g.keep,
196− membersHash: g.membersHash,
197− memberUrls: g.memberUrls,
198− lastNamedAt: g.lastNamedAt,
199−});
200−
201−/**
202− * The window's Parked group: the one on record, or a group already showing the Parked title (one an
203− * older version lost track of after a restart), which becomes Parked again.
204− */
205−async function parkingGroupIn(rt: Runtime, windowId: number): Promise<number | undefined> {
206− const s = rt.state();
207− const known = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
208− if (known !== undefined && (await groupExists(known))) return known;
209− const settings = rt.settings();
210− const shown = (await chrome.tabGroups.query({ windowId }).catch(() => [])).filter((g) => looksParked(g, settings));
211− for (const g of shown) {
212− const rec = s.groups[g.id] ?? newGroupRecord(g.id, windowId, "tidy", "grey");
213− adoptParked(rec, settings);
214− s.groups[g.id] = rec;
215− }
216− return shown[0]?.id;
217−}
218−
219136async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
220137 const settings = rt.settings();
221− const now = rt.now();
222138 const moves: SweepMove[] = [];
223139 const byWindow = new Map<number, TidyTab[]>();
224140 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -226,15 +142,13 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
226142 const ids = tabs.map((t) => t.id);
227143 const s = rt.state();
228144 for (const t of tabs) {
229− const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
230− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
145+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
231146 const rec = s.tabs[t.id];
232− if (rec) {
233− rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234− rec.parkedAt = now;
235− }
147+ const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
148+ if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
236149 }
237− let parking = await parkingGroupIn(rt, windowId);
150+ let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
151+ if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
238152 if (parking === undefined) {
239153 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
240154 } else {
@@ -248,16 +162,12 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
248162 rt.log("could not move parking group to the end", e);
249163 }
250164 if (settings.discardParked) {
251− // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
252165 for (const t of tabs) {
253− if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
166+ if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
254167 }
255168 }
256169 }
257− // Sweeps inside the undo window add up, so Undo puts back everything parked in that hour.
258− const last = rt.state().lastSweep;
259− const kept = last && now - last.at < UNDO_WINDOW_MS ? last.moves.filter((m) => !moves.some((n) => n.tabId === m.tabId)) : [];
260− rt.state().lastSweep = { at: now, moves: [...kept, ...moves] };
170+ rt.state().lastSweep = { at: rt.now(), moves };
261171 rt.commit();
262172 return moves.length;
263173}
@@ -287,40 +197,18 @@ export async function undoSweep(rt: Runtime): Promise<number> {
287197 const last = rt.state().lastSweep;
288198 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
289199 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
290− // Parking every tab of a group removes it: undo makes it again, once, with its title and colour.
291− const remade = new Map<number, number>();
292− const target = async (m: SweepMove): Promise<number | undefined> => {
293− if (m.groupId === -1) return undefined;
294− const again = remade.get(m.groupId);
295− if (again !== undefined) return again;
296− return (await groupExists(m.groupId)) ? m.groupId : undefined;
297− };
298200 let restored = 0;
299201 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
300202 const t = live.get(m.tabId);
301203 if (!t) continue;
302− let groupId = await target(m);
303− if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
304− else if (m.groupId !== -1 && m.group) {
305− const g = m.group;
306− groupId = await createManagedGroup(rt, [m.tabId], m.windowId, g.origin, { title: g.stripTitle ?? "", color: g.color }, { ...g });
307− if (groupId !== undefined) remade.set(m.groupId, groupId);
308− } else {
309− rt.state().ownUngroups[m.tabId] = rt.now(); // back to loose is the worker's move, not the user pulling it out
310− await ungroup(rt, [m.tabId]);
311− }
204+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
205+ else await ungroup(rt, [m.tabId]);
312206 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
313207 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
314− groupId = await target(m);
315− if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
316− else if ((await chrome.tabs.get(m.tabId).catch(() => undefined))?.groupId !== -1) {
317− rt.state().ownUngroups[m.tabId] = rt.now();
318− await ungroup(rt, [m.tabId]);
319− }
208+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
320209 const rec = rt.state().tabs[m.tabId];
321210 if (rec) {
322211 rec.parkedFrom = undefined;
323− rec.parkedAt = undefined;
324212 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
325213 }
326214 restored++;
@@ -361,7 +249,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
361249 if (t.id !== undefined) ids.push(t.id);
362250 }
363251 if (ids.length) {
364− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
252+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
365253 }
366254 return ids.length;
367255}
src/shared/health.ts+2−4
@@ -46,9 +46,7 @@ export function notices(s: Health): Notice[] {
4646 case "HOST_NOT_FOUND":
4747 out.push({
4848 tone: "bad", title: "Finish setting up Diagonal",
49− body: chrome.runtime.getManifest().update_url
50− ? "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal."
51− : "Diagonal's helper isn't registered with this browser yet. Installed with Homebrew? Run this in Terminal, then reopen Diagonal. Installed from a zip? Run: bash <the Diagonal folder>/install-host.command",
49+ body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
5250 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
5351 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
5452 });
@@ -82,7 +80,7 @@ export function notices(s: Health): Notice[] {
8280 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
8381 }
8482 }
85− if (s.timeoutHint) {
83+ if (s.timeoutHint && s.health !== "degraded") {
8684 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." });
8785 }
8886 return out;
src/shared/label.ts+9−42
@@ -7,12 +7,9 @@ export const LABEL_MAX_CHARS = 28;
77export const LABEL_MIN_WORDS = 2;
88export const LABEL_MAX_WORDS = 4;
99
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;
10+const QUOTES = /["'“”‘’«»`]/g;
11+const HAS_QUOTE = /["'“”‘’«»`]/;
1312const 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"]);
1613
1714/** Repair what is repairable; return undefined when the label cannot be made valid. */
1815export function repairLabel(raw: unknown): string | undefined {
@@ -20,49 +17,26 @@ export function repairLabel(raw: unknown): string | undefined {
2017 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
2118 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
2219 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 {
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();
20+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
3021 let label = words.join(" ").replace(TRAILING_PUNCT, "");
3122 if (!label) return undefined;
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);
23+ label = label[0].toLocaleUpperCase() + label.slice(1);
3424 return isValidLabel(label) ? label : undefined;
3525}
3626
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;
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;
4329export const CJK_MIN_CHARS = 2;
4430export const CJK_MAX_CHARS = 16;
4531
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−};
32+const isCjk = (label: string): boolean => (label.match(CJK)?.length ?? 0) * 2 > label.replace(/ /g, "").length;
5933
6034export function isValidLabel(label: string): boolean {
6135 const words = label.split(" ").filter(Boolean);
62− const cjk = noSpace(label);
36+ const cjk = isCjk(label);
6337 return (
6438 label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
65− (cjk ? widthOf(label) <= CJK_MAX_CHARS && label.length <= LABEL_MAX_CHARS : label.length <= LABEL_MAX_CHARS) &&
39+ label.length <= (cjk ? CJK_MAX_CHARS : LABEL_MAX_CHARS) &&
6640 words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
6741 words.length <= LABEL_MAX_WORDS &&
6842 !HAS_QUOTE.test(label) &&
@@ -87,10 +61,3 @@ export function labelOf(title: string | undefined): string {
8761
8862export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
8963 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
90−
91−/**
92− * A page title without the unread counts and bullets sites add to it, "(3) Inbox" or "• Slack", so a count
93− * ticking over does not read as a new page. Only for noticing change: the model still sees the full title.
94− */
95−export const titleKey = (title: string | undefined): string =>
96− (title ?? "").replace(/[(\[]\d[\d,.]*\+?[)\]]/g, "").replace(/^[\s•●▶*]+/u, "").replace(/\s+/g, " ").trim();
test/auto.test.ts+2−84
@@ -15,7 +15,6 @@ let state: State;
1515let calls: OrganizePayload[];
1616let replies: HostReply<unknown>[];
1717let fallbacks: number[];
18−let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
1918
2019const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
2120 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -33,9 +32,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
3332 settings: () => makeSettings(),
3433 commit: () => undefined,
3534 now: () => 1_000_000,
36− host: async <T,>(_op: string, payload: object, opts?: object) => {
35+ host: async <T,>(_op: string, payload: object) => {
3736 calls.push(JSON.parse(JSON.stringify(payload)));
38− return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
37+ return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
3938 },
4039 naming: { touch: () => undefined } as unknown as Runtime["naming"],
4140 refreshBadge: () => undefined,
@@ -49,7 +48,6 @@ beforeEach(() => {
4948 calls = [];
5049 replies = [];
5150 fallbacks = [];
52− respond = undefined;
5351 nextGroup = 100;
5452 (globalThis as any).chrome = {
5553 tabs: {
@@ -94,7 +92,6 @@ describe("auto-organize", () => {
9492 await auto.run(1);
9593 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9694 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97− expect(calls[0].siblingTitles).toEqual(["Rust"]);
9895 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9996 });
10097
@@ -152,83 +149,4 @@ describe("auto-organize", () => {
152149 vi.useRealTimers();
153150 }
154151 });
155−
156− it("one tab the model refuses is found and set aside; the others are still sorted", async () => {
157− const auto = setup([liveTab(1), liveTab(2), liveTab(3, { title: "Refused" }), liveTab(4), liveTab(5)]);
158− // Apple's filter refuses any request carrying tab 3; anything else becomes one topic.
159− respond = (p) =>
160− p.items.some((i) => i.title === "Refused")
161− ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
162− : { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", existing: p.existingGroups.length ? 0 : undefined, members: p.items.map((i) => i.i) }], leftovers: [] } };
163− await auto.run(1);
164− expect(live.find((t) => t.id === 3)!.groupId).toBe(-1);
165− expect(live.filter((t) => t.id !== 3).every((t) => t.groupId === 100)).toBe(true);
166− expect(state.tabs[3].organizeRefused).toBe(organizedKeyOf(live[2]));
167− expect(fallbacks).toHaveLength(0); // not a host problem: no retry scheduled
168−
169− const n = calls.length;
170− live.push(liveTab(6));
171− remember(live[5]);
172− await auto.run(1);
173− expect(calls.slice(n).flatMap((c) => c.items.map((i) => i.title))).toEqual(["Tab 6"]); // the refused tab is not sent again
174−
175− const m = calls.length;
176− live[2].title = "Readable now";
177− await auto.run(1);
178− expect(calls.length).toBeGreaterThan(m); // a new title: it gets another chance
179− });
180−
181− it("a refused request is tried once more without what the pages say", async () => {
182− const auto = setup([liveTab(1), liveTab(2)]);
183− state.tabs[1].description = "Something the filter dislikes";
184− respond = (p) =>
185− p.items.some((i) => i.description)
186− ? { ok: false, error: { code: "GUARDRAIL", message: "blocked" } }
187− : { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [] } };
188− await auto.run(1);
189− expect(calls).toHaveLength(2);
190− expect(live.map((t) => t.groupId)).toEqual([100, 100]);
191− });
192−
193− it("tabs that changed while the model was thinking are left out of its answer", async () => {
194− const auto = setup([liveTab(1), liveTab(2), liveTab(3), liveTab(4)]);
195− respond = () => {
196− live[1].url = "https://elsewhere.com/"; // tab 2 went to another page
197− live = live.filter((t) => t.id !== 4); // tab 4 was closed
198− return { ok: true, result: { groups: [{ title: "Site pages", emoji: "📚", color: "blue", members: [0, 1, 2] }, { title: "Other", emoji: "📚", color: "red", members: [3] }], leftovers: [] } };
199− };
200− await auto.run(1);
201− expect(live.map((t) => [t.id, t.groupId])).toEqual([[1, 100], [2, -1], [3, 100]]);
202− expect(state.tabs[1].organizedKey).toBeDefined();
203− expect(state.tabs[2].organizedKey).toBeUndefined(); // looked at again on its new page
204− });
205−
206− it("a new group left with one tab after the others moved on is not made", async () => {
207− const auto = setup([liveTab(1), liveTab(2), liveTab(3)]);
208− respond = () => {
209− live[1].groupId = 77; // you dragged tab 2 into a group of yours meanwhile
210− return { ok: true, result: { groups: [{ title: "Pair", emoji: "📚", color: "blue", members: [0, 1] }], leftovers: [2] } };
211− };
212− await auto.run(1);
213− expect(live.find((t) => t.id === 1)!.groupId).toBe(-1);
214− expect(nextGroup).toBe(100);
215− });
216−
217− it("an unread count ticking over is not a new page", async () => {
218− const auto = setup([liveTab(1, { title: "(3) Inbox" }), liveTab(2)]);
219− await auto.run(1);
220− expect(calls).toHaveLength(1);
221− live[0].title = "(4) Inbox";
222− await auto.run(1);
223− expect(calls).toHaveLength(1);
224− });
225−
226− it("a host that stops answering stops the run after one smaller try", async () => {
227− const auto = setup(Array.from({ length: 10 }, (_, i) => liveTab(i + 1)));
228− respond = () => ({ ok: false, error: { code: "TIMEOUT", message: "slow" } });
229− await auto.run(1);
230− expect(calls.map((c) => c.items.length)).toEqual([10, 5]);
231− expect(fallbacks).toHaveLength(1);
232− expect(Object.values(state.tabs).some((t) => t.organizeRefused)).toBe(false);
233− });
234152});
test/engine.test.ts−126
@@ -240,14 +240,6 @@ describe("auto-organize triggers", () => {
240240 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
241241 });
242242
243− it("parking keeps the title of the group the tab came from, for the archive", () => {
244− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 20 })], [{ id: 10, origin: "organize", title: "Rust async" }, { id: 20, origin: "tidy" }]);
245− s.tabs[1].parkedFrom = "Rust async"; // set by the sweep just before it moves the tab
246− const moved = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 20 }) }, ctx());
247− const discarded = step(moved.state, { type: "tabUpdated", tab: tab(1, { groupId: 20, status: "unloaded" }) }, ctx());
248− expect(discarded.state.tabs[1].parkedFrom).toBe("Rust async");
249− });
250−
251243 it("opening a parked tab takes it out of Parked", () => {
252244 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
253245 s.tabs[1].parkedFrom = "Rust async";
@@ -318,121 +310,3 @@ describe("fit check", () => {
318310 expect(out.state.tabs[1].handPlaced).toBeUndefined();
319311 });
320312});
321−
322−describe("groups Chromium removes and brings back", () => {
323− const opener = () => {
324− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "opener", title: "🦀 Rust async" }]);
325− s.groups[10].color = "orange";
326− return s;
327− };
328− const run = (s: ReturnType<typeof opener>, events: Parameters<typeof step>[1][], now = 1_000_000) =>
329− events.reduce((acc, e) => step(acc, e, ctx(now)).state, s);
330−
331− it("a group moved to another window stays Diagonal's, and its tabs are not taken for your choices", () => {
332− const moved = run(opener(), [
333− { type: "groupRemoved", groupId: 10 },
334− { type: "tabUpdated", tab: tab(1) },
335− { type: "tabUpdated", tab: tab(2) },
336− { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
337− { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
338− { type: "tabUpdated", tab: tab(2, { groupId: 10, windowId: 2 }) },
339− ]);
340− expect(moved.groups[10]).toMatchObject({ origin: "opener", managed: true, userNamed: false, windowId: 2 });
341− for (const id of [1, 2]) {
342− expect(moved.tabs[id].groupId).toBe(10);
343− expect(moved.tabs[id].keepLoose).toBeUndefined();
344− expect(moved.tabs[id].handPlaced).toBeUndefined();
345− }
346− expect(moved.removedGroups[10]).toBeUndefined();
347− });
348−
349− it("the same when a tab's new group is reported before the group itself", () => {
350− const moved = run(opener(), [
351− { type: "groupRemoved", groupId: 10 },
352− { type: "tabUpdated", tab: tab(1) },
353− { type: "tabUpdated", tab: tab(1, { groupId: 10, windowId: 2 }) },
354− { type: "groupCreated", group: { id: 10, windowId: 2, title: "🦀 Rust async", color: "orange" } },
355− ]);
356− expect(moved.groups[10].origin).toBe("opener");
357− expect(moved.tabs[1].keepLoose).toBeUndefined();
358− expect(moved.tabs[1].handPlaced).toBeUndefined();
359− });
360−
361− it("a closed group reopened untitled, then titled, is Diagonal's again", () => {
362− const closed = run(opener(), [
363− { type: "tabRemoved", tabId: 1 },
364− { type: "tabRemoved", tabId: 2 },
365− { type: "groupRemoved", groupId: 10 },
366− ]);
367− const later = 1_000_000 + 3_600_000;
368− const back = run(closed, [
369− { type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } },
370− { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } },
371− ], later);
372− expect(back.groups[77]).toMatchObject({ origin: "opener", managed: true, userNamed: false, stripTitle: "🦀 Rust async" });
373− });
374−
375− it("a group you make and title yourself later is still yours", () => {
376− const closed = run(opener(), [{ type: "groupRemoved", groupId: 10 }]);
377− const made = run(closed, [{ type: "groupCreated", group: { id: 77, windowId: 1, title: "", color: "orange" } }]);
378− const titled = step(made, { type: "groupUpdated", group: { id: 77, windowId: 1, title: "🦀 Rust async", color: "orange" } }, ctx(1_000_000 + 60_000)).state;
379− expect(titled.groups[77]).toMatchObject({ origin: "user", userNamed: true });
380− });
381−
382− it("ungrouping by hand still keeps the tabs loose", () => {
383− const out = run(opener(), [
384− { type: "tabUpdated", tab: tab(1) },
385− { type: "tabUpdated", tab: tab(2) },
386− { type: "groupRemoved", groupId: 10 },
387− ]);
388− expect(out.tabs[1].keepLoose).toBe("https://example.com/1");
389− });
390−
391− it("keeps a day of removed groups, at most 20", () => {
392− let s = opener();
393− for (let id = 100; id < 125; id++) {
394− s.groups[id] = { ...s.groups[10], id };
395− s = step(s, { type: "groupRemoved", groupId: id }, ctx(1_000_000 + id)).state;
396− }
397− expect(Object.keys(s.removedGroups)).toHaveLength(20);
398− s = step(s, { type: "tabActivated", tabId: 1 }, ctx(1_000_000 + 25 * 3_600_000)).state;
399− expect(Object.keys(s.removedGroups)).toHaveLength(0);
400− });
401−});
402−
403−describe("tabs you place", () => {
404− it("a new tab made inside a group ('New tab in group') is yours there", () => {
405− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
406− const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, url: "brave://newtab/" }) }, ctx());
407− expect(state.tabs[3].handPlaced).toBe(true);
408− });
409−
410− it("a link opened from a grouped tab is not", () => {
411− const opener = tab(1, { groupId: 10 });
412− const s = stateWith([opener, tab(2, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
413− const { state } = step(s, { type: "tabCreated", tab: tab(3, { groupId: 10, openerTabId: 1 }), opener }, ctx());
414− expect(state.tabs[3].handPlaced).toBeUndefined();
415− });
416−
417− it("a tab that joins a group has no loose choice left, even if a late event said it left", () => {
418− const s = stateWith([tab(1), tab(2)], [{ id: 10, origin: "opener" }]);
419− s.tabs[1].keepLoose = "https://example.com/1";
420− s.ownAdds[1] = 1_000_000;
421− const { state } = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
422− expect(state.tabs[1].keepLoose).toBeUndefined();
423− });
424−
425− it("a new tab whose first load redirects is not fit-checked", () => {
426− const s = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "opener" }]);
427− const opened = step(s, { type: "tabCreated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "", pendingUrl: "http://blog.example.org/post", status: "loading" }), opener: tab(1, { groupId: 10 }) }, ctx()).state;
428− const landed = step(opened, { type: "tabUpdated", tab: tab(4, { groupId: 10, openerTabId: 1, url: "https://blog.example.org/post" }) }, ctx());
429− expect(landed.state.tabs[4].fitPending).toBeUndefined();
430− });
431−
432− it("leaving Parked stops the archive clock", () => {
433− const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
434− s.tabs[1].parkedAt = 5;
435− expect(step(s, { type: "tabUpdated", tab: tab(1) }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
436− expect(step(s, { type: "tabActivated", tabId: 1 }, ctx()).state.tabs[1].parkedAt).toBeUndefined();
437− });
438−});
test/naming.test.ts+2−72
@@ -50,10 +50,6 @@ 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− });
5753 it("user-named groups are skipped", () => {
5854 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
5955 });
@@ -155,25 +151,15 @@ describe("naming queue", () => {
155151 expect(state.groups[7].dirty).toBe(false);
156152 });
157153
158− it("waits while a member is still loading", async () => {
154+ it("waits while a member is still loading or the group has one tab", async () => {
159155 members = [member(1), member(2, { status: "loading" })];
160156 naming.touch(7);
161157 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 () => {
167158 members = [member(1)];
168159 naming.touch(7);
169160 await settle();
170161 expect(calls).toHaveLength(0);
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);
162+ expect(state.groups[7].dirty).toBe(true);
177163 });
178164
179165 it("names a group whose member the browser discarded to save memory", async () => {
@@ -220,54 +206,6 @@ describe("naming queue", () => {
220206 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
221207 });
222208
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−
271209 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
272210 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
273211 replies.push(fail);
@@ -299,14 +237,6 @@ describe("naming queue", () => {
299237 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300238 });
301239
302− it("the retry after a title the host rejected lists that title as not allowed", async () => {
303− replies.push({ ok: false, error: { code: "BAD_MODEL_OUTPUT", message: "rules", raw: JSON.stringify({ title: "Python", emoji: "🐍" }) } });
304− naming.touch(7);
305− await settle();
306− expect(calls).toHaveLength(2);
307− expect(calls[1].mustDifferFrom).toEqual(["Python"]);
308− });
309−
310240 it("a group renamed by hand while the model runs keeps the user's title", async () => {
311241 const n = new Naming({
312242 ...(naming as any).d,
test/organize.test.ts−17
@@ -107,23 +107,6 @@ describe("existing groups", () => {
107107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
108108 });
109109
110− it("offers the groups in use when there are more than 12, not the oldest", () => {
111− const s = emptyState();
112− for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}`, lastNamedAt: 1000 - i });
113− s.tabs[1] = { id: 1, windowId: 1, groupId: 14, index: 0, url: "u", title: "Now", pinned: false, createdAt: 0, lastActivatedAt: 5000 };
114− const { ids } = existingGroupsFor(s, 1);
115− expect(ids[0]).toBe(14);
116− expect(ids).toContain(0);
117− expect(ids).not.toContain(13);
118− });
119−
120− it("leaves out the Restored group, which is not a topic", () => {
121− const s = emptyState();
122− s.groups[1] = newGroupRecord(1, 1, "organize", "grey", { userNamed: true, stripTitle: "♻️ Restored", restored: true });
123− s.groups[2] = newGroupRecord(2, 1, "organize", "red", { stripTitle: "🦀 Rust" });
124− expect(existingGroupsFor(s, 1).ids).toEqual([2]);
125− });
126−
127110 it("groups created by batch n come first for batch n+1", () => {
128111 const s = emptyState();
129112 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/sweep.test.tsdeleted−181
@@ -1,181 +0,0 @@
1−import { afterEach, beforeEach, describe, expect, it } from "vitest";
2−import type { Runtime } from "../src/background/runtime";
3−import { emptyState, newGroupRecord, type State } from "../src/background/state";
4−import { HOUR, runSweep, undoSweep } from "../src/background/tidy";
5−import { settings as makeSettings } from "./helpers";
6−
7−/** The tidy sweep against a small in-memory strip with Chromium's group rules: an emptied group is removed. */
8−
9−type LiveTab = {
10− id: number; windowId: number; index: number; groupId: number; url: string; title: string;
11− pinned: boolean; active: boolean; discarded: boolean; autoDiscardable: boolean; lastAccessed: number;
12−};
13−type LiveGroup = { id: number; windowId: number; title: string; color: string; collapsed: boolean };
14−
15−const T0 = 10_000 * HOUR;
16−let now: number;
17−let live: LiveTab[];
18−let groups: Map<number, LiveGroup>;
19−let nextGroup: number;
20−let state: State;
21−let discarded: number[];
22−
23−const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
24− id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/p`, title: `Tab ${id}`,
25− pinned: false, active: false, discarded: false, autoDiscardable: true, lastAccessed: T0 - 60 * HOUR, ...over,
26−});
27−
28−function remember(t: LiveTab): void {
29− state.tabs[t.id] = { id: t.id, windowId: t.windowId, groupId: t.groupId, index: t.index, url: t.url, title: t.title, pinned: t.pinned, createdAt: 0, lastActivatedAt: t.lastAccessed };
30−}
31−
32−const dropEmpty = () => {
33− for (const id of [...groups.keys()]) if (!live.some((t) => t.groupId === id)) groups.delete(id);
34−};
35−
36−const rt: Runtime = {
37− state: () => state,
38− settings: () => makeSettings(),
39− commit: () => undefined,
40− now: () => now,
41− host: async () => ({ ok: false, error: { code: "FM_ERROR", message: "not used" } }) as never,
42− naming: { touch: () => undefined } as unknown as Runtime["naming"],
43− refreshBadge: () => undefined,
44− log: () => undefined,
45−};
46−
47−beforeEach(() => {
48− now = T0;
49− state = emptyState();
50− groups = new Map();
51− nextGroup = 500;
52− discarded = [];
53− (globalThis as any).chrome = {
54− tabs: {
55− query: async (q: { windowId?: number; groupId?: number }) =>
56− live.filter((t) => (q.windowId === undefined || t.windowId === q.windowId) && (q.groupId === undefined || t.groupId === q.groupId)).map((t) => ({ ...t })),
57− get: async (id: number) => ({ ...live.find((t) => t.id === id)! }),
58− group: async ({ tabIds, groupId, createProperties }: { tabIds: number[]; groupId?: number; createProperties?: { windowId: number } }) => {
59− let id = groupId;
60− if (id === undefined) {
61− id = nextGroup++;
62− groups.set(id, { id, windowId: createProperties?.windowId ?? 1, title: "", color: "grey", collapsed: false });
63− }
64− for (const t of live) if (tabIds.includes(t.id)) t.groupId = id;
65− dropEmpty();
66− return id;
67− },
68− ungroup: async (ids: number[]) => {
69− for (const t of live) if (ids.includes(t.id)) t.groupId = -1;
70− dropEmpty();
71− },
72− move: async () => undefined,
73− discard: async (id: number) => {
74− discarded.push(id);
75− live.find((t) => t.id === id)!.discarded = true;
76− },
77− remove: async (ids: number[]) => {
78− live = live.filter((t) => !ids.includes(t.id));
79− dropEmpty();
80− },
81− },
82− tabGroups: {
83− get: async (id: number) => {
84− const g = groups.get(id);
85− if (!g) throw new Error(`No group with id: ${id}.`);
86− return { ...g };
87− },
88− query: async (q: { windowId?: number }) => [...groups.values()].filter((g) => q.windowId === undefined || g.windowId === q.windowId).map((g) => ({ ...g })),
89− update: async (id: number, props: Partial<LiveGroup>) => Object.assign(groups.get(id)!, props),
90− move: async () => undefined,
91− },
92− };
93−});
94−
95−afterEach(() => {
96− delete (globalThis as any).chrome;
97−});
98−
99−function strip(tabs: LiveTab[], made: LiveGroup[] = []): void {
100− live = tabs;
101− for (const g of made) groups.set(g.id, g);
102− for (const t of tabs) remember(t);
103−}
104−
105−const parkedGroupId = () => Object.values(state.groups).find((g) => g.origin === "tidy")?.id;
106−
107−describe("tidy sweep", () => {
108− it("parks a stale tab and closes it only after it has sat in Parked for the archive time", async () => {
109− strip([liveTab(1), liveTab(2, { active: true })]);
110− expect(await runSweep(rt)).toMatchObject({ parked: 1, archived: 0 });
111− expect(live.find((t) => t.id === 1)!.groupId).toBe(parkedGroupId());
112− expect(state.tabs[1].parkedAt).toBe(T0);
113−
114− now = T0 + 47 * HOUR;
115− expect(await runSweep(rt)).toMatchObject({ archived: 0 });
116− now = T0 + 48 * HOUR;
117− expect(await runSweep(rt)).toMatchObject({ archived: 1 });
118− expect(state.archive.map((a) => a.url)).toEqual(["https://site1.com/p"]);
119− });
120−
121− it("two sweeps at once archive a tab once", async () => {
122− strip([liveTab(1, { groupId: 9 }), liveTab(2, { active: true })], [{ id: 9, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
123− state.groups[9] = newGroupRecord(9, 1, "tidy", "grey", { userNamed: true, stripTitle: "💤 Parked" });
124− state.tabs[1].parkedAt = T0 - 49 * HOUR;
125− await Promise.all([runSweep(rt), runSweep(rt, true)]);
126− expect(state.archive).toHaveLength(1);
127− });
128−
129− it("a Parked group whose record an older version lost is Parked again", async () => {
130− strip([liveTab(1, { groupId: 50 }), liveTab(2)], [{ id: 50, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
131− state.groups[50] = newGroupRecord(50, 1, "user", "grey", { userNamed: true, stripTitle: "💤 Parked" });
132− await runSweep(rt);
133− expect(live.find((t) => t.id === 2)!.groupId).toBe(50);
134− expect(groups.size).toBe(1);
135− expect(state.groups[50]).toMatchObject({ origin: "tidy", managed: true });
136− });
137−
138− it("does not discard a tab marked never to be discarded", async () => {
139− strip([liveTab(1), liveTab(2, { autoDiscardable: false }), liveTab(3, { active: true })]);
140− await runSweep(rt);
141− expect(discarded).toEqual([1]);
142− });
143−});
144−
145−describe("undo tidy", () => {
146− it("brings back a group that parking emptied, with its title", async () => {
147− strip(
148− [liveTab(1, { active: true }), liveTab(2, { groupId: 7 }), liveTab(3, { groupId: 7 })],
149− [{ id: 7, windowId: 1, title: "🦀 Rust async", color: "orange", collapsed: false }],
150− );
151− state.groups[7] = newGroupRecord(7, 1, "organize", "orange", { title: "Rust async", emoji: "🦀", stripTitle: "🦀 Rust async" });
152− await runSweep(rt);
153− expect(groups.has(7)).toBe(false);
154−
155− expect(await undoSweep(rt)).toBe(2);
156− const back = live.find((t) => t.id === 2)!.groupId;
157− expect(back).not.toBe(-1);
158− expect(live.find((t) => t.id === 3)!.groupId).toBe(back);
159− expect(groups.get(back)).toMatchObject({ title: "🦀 Rust async", color: "orange" });
160− expect(state.groups[back]).toMatchObject({ origin: "organize", title: "Rust async", userNamed: false });
161− expect(state.tabs[2].parkedAt).toBeUndefined();
162− });
163−
164− it("puts loose tabs back as the worker's own move, so they are not taken for tabs you pulled out", async () => {
165− strip([liveTab(1, { active: true }), liveTab(4)]);
166− await runSweep(rt);
167− await undoSweep(rt);
168− expect(live.find((t) => t.id === 4)!.groupId).toBe(-1);
169− expect(state.ownUngroups[4]).toBeDefined();
170− });
171−
172− it("covers every sweep in the last hour, not only the latest", async () => {
173− strip([liveTab(1, { active: true }), liveTab(2), liveTab(3, { lastAccessed: T0 - 23.5 * HOUR })]);
174− state.tabs[3].lastActivatedAt = T0 - 23.5 * HOUR;
175− await runSweep(rt, true);
176− now = T0 + HOUR / 2;
177− await runSweep(rt);
178− expect(live.filter((t) => t.groupId !== -1).map((t) => t.id)).toEqual([2, 3]);
179− expect(await undoSweep(rt)).toBe(2);
180− });
181−});
test/tidy.test.ts+3−34
@@ -34,19 +34,6 @@ describe("park eligibility", () => {
3434 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
3535 });
3636
37− it("a Diagonal group you renamed counts as yours", () => {
38− const s2 = emptyState();
39− s2.groups[14] = newGroupRecord(14, 1, "organize", "red", { userNamed: true, stripTitle: "Tax 2026" });
40− expect(isParkCandidate(tt({ groupId: 14 }), s2, cfg, NOW)).toBe(false);
41− expect(isParkCandidate(tt({ groupId: 14 }), s2, settings({ tidyUserGroups: true }), NOW)).toBe(true);
42− });
43−
44− it("after a restart, the stored last use wins over Chromium's restore time", () => {
45− const s2 = emptyState();
46− s2.tabs[1] = { id: 1, windowId: 1, groupId: -1, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 30 * HOUR };
47− expect(isParkCandidate(tt({ lastAccessed: NOW - 60_000 }), s2, cfg, NOW)).toBe(true);
48− });
49−
5037 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
5138 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
5239 });
@@ -64,32 +51,14 @@ describe("park eligibility", () => {
6451});
6552
6653describe("archive", () => {
67− const record = (id: number, over: object = {}) => ({ id, windowId: 1, groupId: 12, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 60 * HOUR, ...over });
68−
69− it("parked tabs that sat in Parked past archiveAfter are archived; 0 means never", () => {
54+ it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
7055 const s = emptyState();
7156 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
72− s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
73− const parked = tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR });
74− expect(archiveCandidates([parked, tt({ id: 2 })], s, settings(), NOW)).toEqual([parked]);
57+ const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
58+ expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
7559 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
7660 });
7761
78− it("counts from when the tab was parked, not from when it was last used", () => {
79− const s = emptyState();
80− s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
81− s.tabs[1] = record(1, { parkedAt: NOW - HOUR }); // unused for 60 h, but parked only an hour ago
82− s.tabs[2] = record(2); // in Parked with no parking time: its clock has not started
83− expect(archiveCandidates([tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR }), tt({ id: 2, groupId: 12 })], s, settings(), NOW)).toEqual([]);
84− });
85−
86− it("an address added to Never tidy is not closed from Parked", () => {
87− const s = emptyState();
88− s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
89− s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
90− expect(archiveCandidates([tt({ groupId: 12, url: "https://jira.corp/x" })], s, settings({ tidyExclusions: ["jira."] }), NOW)).toEqual([]);
91− });
92−
9362 it("is newest first and capped, oldest dropped", () => {
9463 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
9564 const existing = [3, 2, 1].map(entry);
test/url.test.ts+13−16
@@ -1,8 +1,7 @@
11import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji } 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";
65
76describe("url helpers", () => {
87 it("strips www.", () => {
@@ -46,12 +45,19 @@ describe("url helpers", () => {
4645 });
4746});
4847
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][];
51−
5248describe("labels", () => {
53− it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
54− expect(repairLabel(raw) ?? null).toBe(fixed);
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+ });
58+
59+ it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
60+ expect(repairLabel(raw)).toBeUndefined();
5561 });
5662
5763 it("validates", () => {
@@ -68,15 +74,6 @@ describe("labels", () => {
6874 expect(safeEmoji("🦄")).toBe("🧭");
6975 });
7076
71− it("titleKey ignores the unread counts and bullets sites put in titles", () => {
72− expect(titleKey("(3) Inbox - Mail")).toBe(titleKey("(12) Inbox - Mail"));
73− expect(titleKey("Inbox (1,204) - me@example.com - Gmail")).toBe("Inbox - me@example.com - Gmail");
74− expect(titleKey("• Slack | general")).toBe("Slack | general");
75− expect(titleKey("[99+] Chat")).toBe("Chat");
76− expect(titleKey("Rust async")).toBe("Rust async");
77− expect(titleKey("Rust async")).not.toBe(titleKey("Rust sync"));
78− });
79−
8077 it("labelOf strips our emoji prefix", () => {
8178 expect(labelOf("🦀 Rust async")).toBe("Rust async");
8279 expect(labelOf("Plain title")).toBe("Plain title");
tsconfig.json−1
@@ -9,7 +9,6 @@
99 "noEmit": true,
1010 "skipLibCheck": true,
1111 "isolatedModules": true,
12− "resolveJsonModule": true,
1312 "noFallthroughCasesInSwitch": true,
1413 "forceConsistentCasingInFileNames": true
1514 },