on master help sign in
30 files changed+2014−301
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+23−8 README.md+6−3 host/diagonal-host.py+89−41 host/tests/fake_fm.py+5 host/tests/golden/labels.json+102 host/tests/test_host.py+108−4 host/tests/test_prompts_validate.py+8 host/validate.py+102−14 scripts/install-manifest.sh+11−2 src/background/engine.ts+136−5 src/background/fit.ts+122 src/background/host.ts+3 src/background/index.ts+128−36 src/background/naming.ts+85−27 src/background/organize.ts+204−111 src/background/runtime.ts+16−2 src/background/state.ts+24 src/background/tidy.ts+129−17 src/shared/health.ts+4−2 src/shared/label.ts+49−8 test/auto.test.ts+90−2 test/e2e/smoke.mjs+27 test/engine.test.ts+185−1 test/fit.test.ts+18 test/naming.test.ts+80−2 test/organize.test.ts+24 test/sweep.test.ts+181 test/tidy.test.ts+34−3 test/url.test.ts+20−13 tsconfig.json+1
.github/workflows/release.yml+23−8
@@ -9,8 +9,11 @@ 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.
1214concurrency:
13− group: release-${{ github.sha }}
15+ group: release
16+ cancel-in-progress: false
1417
1518jobs:
1619 release:
@@ -32,8 +35,23 @@ jobs:
3235
3336 - name: Version from commit time
3437 id: version
38+ env:
39+ GH_TOKEN: ${{ github.token }}
3540 run: |
36− version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
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
3755 echo "version=$version" >> "$GITHUB_OUTPUT"
3856 echo "Version $version"
3957
@@ -54,22 +72,19 @@ jobs:
5472 npm run zip:store
5573
5674 - name: Publish release
75+ if: steps.version.outputs.published != 'true'
5776 env:
5877 GH_TOKEN: ${{ github.token }}
5978 VERSION: ${{ steps.version.outputs.version }}
6079 run: |
6180 asset="diagonal-extension-${VERSION}.zip"
6281 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"
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
82+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
6983
7084 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
7185 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
7286 - name: Upload to the Chrome Web Store
87+ if: steps.version.outputs.published != 'true'
7388 env:
7489 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
7590 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
README.md+6−3
@@ -10,9 +10,10 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
10102. **Auto-organize.** Any other loose tab (typed address, bookmark, new window) is sorted once the window has been quiet for 8 s: it joins one of Diagonal's groups, pairs up with other loose tabs on the same topic, or stays loose if nothing fits. A loose tab is looked at again only when it goes to another page or a new tab arrives that it might pair with.
11113. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
12124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
13−5. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
13+5. **Drift.** When a tab in a group Diagonal made goes to a page on a different topic, it leaves the group once you switch away from it, and auto-organize files it with its new topic. Diagonal compares its topic with up to five groupmates (`src/background/fit.ts`) and only moves it when those groupmates still agree with each other.
14+6. **Tidy.** Tabs idle for 24 h move to a collapsed grey "Parked" group at the end of the strip. Opening a parked tab takes it back out and auto-organize places it. After another 48 h in Parked, tabs are archived and closed, with restore from the popup.
1415
15−Your own choices win: a tab you take out of a group stays out until it goes to another page, groups you make yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
16+Your own choices win: a tab you take out of a group stays out until it goes to another page, a tab you drag into a group stays there, groups you make or rename yourself are left alone, and a title you type is kept. The popup still has **Organize now**, **Tidy now** and their undo, but none of them is needed.
1617
1718The model sees a tab's title, its address, and up to 500 characters of what the page says: its description tag, its main heading and the opening lines of its main text (never form fields). Settings → Privacy can leave the page text out or send only the site name. When a batch of tabs doesn't fit the model's context, the page text is shortened first, then dropped.
1819
@@ -139,7 +140,9 @@ Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm`
139140- Organize asks for one broad topic per tab ("Programming", "Travel"), and the host groups tabs that share one, joins a topic to an existing group when that group's example tabs got the same topic, and names each new group with the name call. Asking the model for whole groups did not work: it put nearly every new group into existing group 0, and specific topics gave every tab its own.
140141- `fm respond` reads the prompt from stdin when no prompt argument is given. The host passes its fixed rules with `-i` and the tab text on stdin, so tab titles never show in the process list and are harder to use to override the rules. There is no timeout flag; the host enforces its own.
141142- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
142−- The context is about 8,000 tokens for prompt and reply together (38,000 characters worked, 40,000 overflowed). `CHAR_BUDGET` is 24,000 characters.
143+- The context holds prompt and reply together: a 7,400-token prompt worked and 13,100 overflowed. Tokens per character differ a lot by language (English 0.26, Russian 0.31, Chinese 0.54, Japanese 0.51), so the host budgets tokens, not characters: `TOKEN_BUDGET` is 7,000. It estimates from the characters and, near the limit, asks `fm count-tokens -q -i ...` (under 0.1 s) for the exact number. `count-tokens` accepts `-i` but not `--schema`.
144+- `--greedy` makes answers repeatable: five runs gave one answer, where default sampling gave three different names and sometimes split topics ("Finance" and "Banking"). The host always passes it.
145+- `fm serve` (an OpenAI-style server on a port or unix socket, with `json_schema` output) saved only about 100 ms per call once warm, can't take `--greedy` or guardrail settings, and on a TCP port answers any web page (it reflects any CORS origin). Diagonal keeps spawning `fm respond`.
143146- The safety layer sometimes refuses harmless text ("The model's safety guardrails were triggered."). The host retries such a call once with `--guardrails permissive-content-transformations`.
144147- Reply JSON key order is not stable, and the model can list a tab both in a group and in leftovers; the validator handles both.
145148
host/diagonal-host.py+89−41
@@ -36,9 +36,13 @@ SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/A
3636SCHEMAS = os.path.join(SUPPORT, "schemas")
3737LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
3838LOG_MAX_BYTES = 5 * 1024 * 1024
39−# fm's context is about 8,000 tokens for prompt and reply together (measured on macOS 27: 38,000 chars of
40−# prompt worked, 40,000 overflowed, ~4.7 chars/token). 24,000 chars leaves room for the reply and dense text.
41−CHAR_BUDGET = 24_000
39+# fm's context holds prompt and reply together. Measured on macOS 27 with the topics schema: a 7,400-token
40+# prompt worked, 13,100 overflowed. 7,000 tokens leaves room for the reply.
41+TOKEN_BUDGET = 7_000
42+# Tokens per character, measured with `fm count-tokens` on tab lists: English 0.26, Russian 0.31, Chinese 0.54,
43+# Japanese 0.51. The estimate rounds up, so a prompt it calls small enough is; near the limit fm counts exactly.
44+TOKENS_PER_CHAR = {"ascii": 0.3, "cjk": 0.6, "other": 0.36}
45+EXACT_COUNT_ABOVE = 0.75 # of TOKEN_BUDGET
4246MAX_REQUEST = 64 * 1024 * 1024
4347MAX_REPLY = 1024 * 1024
4448OPS = {"ping", "name", "organize"}
@@ -191,20 +195,41 @@ def schema_path(name):
191195DESC_CAPS = (500, 200, 0)
192196
193197
198+def estimate_tokens(prompt):
199+ n = 0.0
200+ for ch in prompt.instructions + prompt.text:
201+ n += TOKENS_PER_CHAR["ascii" if ch < "\x80" else "cjk" if validate.CJK.match(ch) else "other"]
202+ return int(n) + 60 # fm's own framing around instructions and prompt
203+
204+
205+def count_tokens(prompt):
206+ """fm's exact count (under 0.1 s), or None when it can't give one."""
207+ try:
208+ 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())
210+ except (OSError, subprocess.TimeoutExpired):
211+ return None
212+ out = p.stdout.strip()
213+ return int(out) if p.returncode == 0 and out.isdigit() else None
214+
215+
216+def prompt_tokens(prompt):
217+ estimate = estimate_tokens(prompt)
218+ if estimate <= TOKEN_BUDGET * EXACT_COUNT_ABOVE:
219+ return estimate
220+ return count_tokens(prompt) or estimate
221+
222+
194223def fit(build, n_items):
224+ """The prompt with as much page text as fits TOKEN_BUDGET; OVER_BUDGET with how many items would fit if
225+ even the titles alone are too long."""
195226 for cap in DESC_CAPS:
196227 prompt = build(cap)
197− if prompts.size(prompt) <= CHAR_BUDGET:
228+ tokens = prompt_tokens(prompt)
229+ if tokens <= TOKEN_BUDGET:
198230 return prompt
199− check_budget(prompt, n_items) # raises OVER_BUDGET with how many items would fit
200− return prompt
201−
202−
203−def check_budget(prompt, n_items):
204− size = prompts.size(prompt)
205− if size > CHAR_BUDGET:
206− allowed = max(1, int(n_items * CHAR_BUDGET / size))
207− raise Fail("OVER_BUDGET", f"prompt is {size} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
231+ allowed = max(1, int(n_items * TOKEN_BUDGET / tokens))
232+ raise Fail("OVER_BUDGET", f"prompt is about {tokens} tokens, budget {TOKEN_BUDGET}", retryable=True, allowedItems=allowed)
208233
209234
210235def extract_json(stdout):
@@ -230,26 +255,41 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
230255USAGE_EXIT = 64
231256
232257
233−def run_fm(prompt, schema, model, timeout_s):
234− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
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."""
235266 try:
236− return _run_fm(prompt, schema, model, timeout_s, [])
267+ return _run_fm(prompt, schema, opts, [])
237268 except Fail as f:
238− if f.code != "GUARDRAIL":
269+ if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
239270 raise
240− return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
271+ return _run_fm(prompt, schema, opts, PERMISSIVE)
272+
273+
274+def time_left(opts):
275+ return opts["deadline"] - time.time()
241276
242277
243−def _run_fm(prompt, schema, model, timeout_s, extra):
278+def _run_fm(prompt, schema, opts, extra):
244279 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
245280 # in the process list. fm has no timeout of its own; subprocess enforces ours.
246− args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "-i", prompt.instructions, *extra]
281+ # --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))
247287 try:
248− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
288+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
249289 except FileNotFoundError:
250290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
251291 except subprocess.TimeoutExpired:
252− raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
292+ raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
253293 if p.returncode != 0:
254294 code = classify(p.stderr, p.returncode)
255295 if code == "LICENSE_REQUIRED":
@@ -268,11 +308,11 @@ def organize_mode():
268308
269309def op_ping(_payload, _opts):
270310 if not os.path.exists(FM):
271− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
311+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
272312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
273313 license_required = False
274314 try:
275− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
315+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
276316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
277317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
278318 license_required, msg = True, LICENSE_MESSAGE
@@ -340,7 +380,7 @@ def op_name(payload, opts):
340380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
341381 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
342382 try:
343− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
383+ out = run_fm(prompt, schema, opts)
344384 except Fail as f:
345385 raise _fit_overflow(f, len(payload["items"]))
346386 try:
@@ -356,7 +396,7 @@ def op_organize(payload, opts):
356396 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
357397 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
358398 try:
359− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
399+ out = run_fm(prompt, schema, opts)
360400 except Fail as f:
361401 raise _fit_overflow(f, len(payload["items"]))
362402 _, owner = prompts.topic_items(payload)
@@ -364,29 +404,33 @@ def op_organize(payload, opts):
364404 result = validate.organize_from_topics(out, payload, owner)
365405 except validate.ValidationError as e:
366406 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
367− chars = prompts.size(prompt) + name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
407+ chars = prompts.size(prompt)
408+ if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
409+ chars += name_new_groups(result, payload, opts)
368410 return result, chars, "topics"
369411
370412
371−def name_new_groups(result, payload, opts, deadline):
413+def name_new_groups(result, payload, opts):
372414 """Title each new group with the name op (well under a second each on fm), so groups appear named.
373− A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
374− taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
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]
375418 chars = 0
376419 for g in result["groups"]:
377420 if "existing" in g or g.get("title"):
378421 continue
379− left = deadline - time.time()
380− if left < 5:
422+ if time_left(opts) < MIN_CALL_S:
381423 break
382424 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
383425 try:
384− named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
426+ named, n, _ = op_name(sub, {**opts, "strict": False})
385427 except Fail:
386428 continue
429+ chars += n
430+ if any(validate.same_label(named["title"], t) for t in taken):
431+ continue
387432 g["title"], g["emoji"] = named["title"], named["emoji"]
388433 taken.append(named["title"])
389− chars += n
390434 return chars
391435
392436
@@ -400,8 +444,8 @@ def handle(req):
400444 raw_opts = req.get("opts") or {}
401445 timeout_ms = raw_opts.get("timeoutMs", 45000)
402446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
403− opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
404− "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
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}
405449 op = req["op"]
406450 if op == "ping":
407451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -455,7 +499,7 @@ def install_schemas():
455499 if "{%s}" % key not in args:
456500 continue
457501 try:
458− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
502+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
459503 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
460504 print(f" {name}: fm failed ({e})", file=sys.stderr)
461505 return "error"
@@ -467,7 +511,7 @@ def install_schemas():
467511 subs["{%s}" % key] = p.stdout.strip()
468512 args = [subs.get(a, a) for a in args]
469513 try:
470− p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
514+ p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
471515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
472516 print(f" {name}: fm failed ({e})", file=sys.stderr)
473517 return "error"
@@ -524,14 +568,16 @@ def selftest():
524568
525569 ping = op_ping({}, {})
526570 check("fm found", os.path.exists(FM), FM)
527− if ping["licenseRequired"]:
571+ if not os.path.exists(FM):
572+ return 1 # nothing else can work without it
573+ if ping.get("licenseRequired"):
528574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
529575 else:
530576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
531577 for name in SCHEMA_COMMANDS:
532578 path = os.path.join(SCHEMAS, name)
533579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
534− hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
580+ hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
535581 check(f"schema {name}", readable, path if readable else hint)
536582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
537583 if ping["fmAvailable"] and schema_path("name.json"):
@@ -641,7 +687,9 @@ def register(host_path):
641687 f.write("\n".join(written) + "\n")
642688 except OSError:
643689 pass
644− return 1 if failures else 0
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
645693
646694
647695def unregister():
host/tests/fake_fm.py+5
@@ -6,6 +6,7 @@ control.json keys:
66 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
88 schema_fail: true → every `fm schema` fails
9+ count_tokens: {"stdout": str, "exit": int} for `fm count-tokens` (default: stdin length / 4)
910 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
1011Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
1112(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
@@ -41,6 +42,10 @@ def main():
4142 spec = {"exit": 0 if agreed else 1}
4243 elif control.get("license"):
4344 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
45+ elif cmd == "count-tokens":
46+ # Like fm: a bare integer. Tests can pin it; otherwise a rough 4 characters per token of the text.
47+ text = sys.stdin.read()
48+ spec = control.get("count_tokens", {"stdout": f"{len(text) // 4}\n", "exit": 0})
4449 elif cmd == "available":
4550 spec = control.get("available", {"stdout": "available", "exit": 0})
4651 elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
host/tests/golden/labels.jsonnew file+102
@@ -0,0 +1,102 @@
1+[
2+ [
3+ "\"Rust async runtimes.\"",
4+ "Rust async runtimes"
5+ ],
6+ [
7+ "rust async runtimes",
8+ "Rust async runtimes"
9+ ],
10+ [
11+ "Rust tabs and docs",
12+ "Rust and docs"
13+ ],
14+ [
15+ "One two three four five six",
16+ "One two three four"
17+ ],
18+ [
19+ "Machine learning research papers reading list",
20+ "Machine learning research"
21+ ],
22+ [
23+ "iPhone camera tips",
24+ "iPhone camera tips"
25+ ],
26+ [
27+ "macOS updates",
28+ "macOS updates"
29+ ],
30+ [
31+ "Children's books",
32+ "Children's books"
33+ ],
34+ [
35+ "'Quoted' title words",
36+ "Quoted title words"
37+ ],
38+ [
39+ "Planning a trip to Japan",
40+ "Planning a trip"
41+ ],
42+ [
43+ "Recipes for",
44+ null
45+ ],
46+ [
47+ "Python教程",
48+ "Python教程"
49+ ],
50+ [
51+ "React 教程",
52+ "React 教程"
53+ ],
54+ [
55+ "日本旅行",
56+ "日本旅行"
57+ ],
58+ [
59+ "京都の旅行計画",
60+ "京都の旅行計画"
61+ ],
62+ [
63+ "日本旅行。",
64+ "日本旅行"
65+ ],
66+ [
67+ "这是一个非常非常非常非常非常长的标题名字",
68+ "这是一个非常非常非常非常非常长的"
69+ ],
70+ [
71+ "Kubernetes 集群部署与运维指南 最佳实践",
72+ "Kubernetes 集群部署与运维指南"
73+ ],
74+ [
75+ "ทริปญี่ปุ่น",
76+ "ทริปญี่ปุ่น"
77+ ],
78+ [
79+ "日",
80+ null
81+ ],
82+ [
83+ "Tabs",
84+ null
85+ ],
86+ [
87+ "Misc stuff",
88+ null
89+ ],
90+ [
91+ "",
92+ null
93+ ],
94+ [
95+ "x",
96+ null
97+ ],
98+ [
99+ "Supercalifragilisticexpialidocious extraordinarily",
100+ null
101+ ]
102+]
host/tests/test_host.py+108−4
@@ -124,9 +124,9 @@ class Name(HostCase):
124124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125125 self.assertGreater(r["meta"]["promptChars"], 0)
126126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:7], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128− self.assertIn("You name a browser tab group", argv[7])
129− self.assertEqual(len(argv), 8)
127+ self.assertEqual(argv[:8], ["respond", "--model", "system", "--no-stream", "--greedy", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128+ self.assertIn("You name a browser tab group", argv[8])
129+ self.assertEqual(len(argv), 9)
130130 # Tab text goes on stdin, never into argv where `ps` would show it.
131131 self.assertIn("tokio.rs", self.stdin_log()[0])
132132 self.assertNotIn("tokio", " ".join(argv))
@@ -210,9 +210,39 @@ class ErrorCodes(HostCase):
210210 r = self.call("name", {"items": items})
211211 self.assertTrue(r["ok"], r)
212212 body = self.stdin_log()[0]
213− self.assertLessEqual(len(body), host.CHAR_BUDGET)
213+ self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214214 self.assertIn("word", body)
215215
216+ def test_chinese_pages_are_measured_in_tokens(self):
217+ # 30 Chinese tabs: few characters, but about twice the tokens per character of English. fm's own
218+ # count says it's too long, so the page text is shortened.
219+ items = [{"title": f"页面 {n}", "url": f"https://example.cn/{n}", "description": "中文内容" * 100} for n in range(30)]
220+ self.control({"count_tokens": {"stdout": "9000\n", "exit": 0},
221+ "respond": {"stdout": json.dumps({"title": "阅读清单", "emoji": "📚"}), "exit": 0}})
222+ r = self.call("name", {"items": items})
223+ self.assertTrue(r["ok"], r)
224+ self.assertIn("count-tokens", [a[0] for a in self.argv_log()])
225+ body = self.stdin_log()[0]
226+ self.assertIn("中文内容", body)
227+ self.assertNotIn("中文内容" * 60, body) # 500 characters didn't fit; 200 did
228+
229+ def test_over_budget_by_fms_count_reports_allowed_items(self):
230+ items = [{"title": "中文标题" * 30, "url": f"https://example.cn/{n}"} for n in range(100)]
231+ self.control({"count_tokens": {"stdout": "14000\n", "exit": 0}})
232+ r = self.call("name", {"items": items})
233+ self.assertEqual(r["error"]["code"], "OVER_BUDGET")
234+ self.assertEqual(r["error"]["allowedItems"], 50)
235+
236+ def test_short_prompts_skip_the_exact_count(self):
237+ self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
238+ self.call("name", {"items": ITEMS3})
239+ self.assertNotIn("count-tokens", [a[0] for a in self.argv_log()])
240+
241+ def test_estimate_covers_the_measured_counts(self):
242+ # Measured with fm count-tokens on macOS 27 (tab lists): the estimate must never be lower.
243+ for text, measured in (("a" * 3072, 810), ("я" * 1274, 400), ("中" * 476, 255), ("の" * 384, 195)):
244+ self.assertGreaterEqual(host.estimate_tokens(host.prompts.Prompt("", text)), measured, text[0])
245+
216246 def test_over_budget_precheck_reports_allowed_items(self):
217247 big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
218248 r = self.call("name", {"items": big})
@@ -257,6 +287,19 @@ class ErrorCodes(HostCase):
257287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
258288
259289
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+
260303class Organize(HostCase):
261304 ITEMS = ITEMS3 + [
262305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -301,6 +344,51 @@ class Organize(HostCase):
301344 self.assertIn("Tokio", name_calls[0])
302345 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
303346
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+
374+ def test_two_topics_joining_one_group_make_one_join(self):
375+ # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376+ self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
377+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
378+ joins = [g for g in r["result"]["groups"] if "existing" in g]
379+ self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
380+
381+ def test_fm_answers_greedily(self):
382+ self.topics([(0, "Programming")])
383+ self.call("organize", {"items": self.ITEMS})
384+ self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
385+
386+ def test_topics_only_when_asked_not_to_name(self):
387+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Travel")])
388+ r = self.call("organize", {"items": self.ITEMS, "nameGroups": False})
389+ self.assertTrue(r["ok"], r)
390+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
391+
304392 def test_a_topic_of_one_tab_is_a_leftover(self):
305393 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
306394 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
@@ -439,6 +527,15 @@ class Register(HostCase):
439527 self.assertEqual(p.returncode, 1)
440528 self.assertIn("missing or not executable", p.stderr)
441529
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+
442539 def test_no_browser_found(self):
443540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
444541 p = self.run_copy("--register", self.host_copy)
@@ -460,6 +557,13 @@ class SelfTest(HostCase):
460557 self.assertEqual(code, 1)
461558 self.assertIn("FAIL model available", p.stdout.decode())
462559
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+
463567 def test_selftest_names_the_license_fix(self):
464568 self.control({"license": True})
465569 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+8
@@ -1,3 +1,4 @@
1+import json
12import os
23import unittest
34
@@ -73,6 +74,13 @@ class ValidateName(unittest.TestCase):
7374 with self.assertRaises(validate.ValidationError, msg=bad):
7475 self.v(bad)
7576
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)
83+
7684 def test_emoji_default(self):
7785 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
7886 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
host/validate.py+102−14
@@ -7,8 +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 = "\"'“”‘’«»`"
11−TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—]+$")
10+TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
1211
1312
1413class ValidationError(Exception):
@@ -89,34 +88,82 @@ def default_emoji(items):
8988 return CATEGORIES[category_for_host(majority)][0]
9089
9190
91+# Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
92+_QUOTE = re.compile(r"(?<![^\W\d_])['’]|['’](?![^\W\d_])|[\"“”‘«»`]")
93+# A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
94+DANGLING = {"a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"}
95+
96+
9297def repair_label(raw):
93− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
98+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars (16 wide characters
99+ for no-space scripts), drop a dangling "to"/"of", capitalise. Kept in step with src/shared/label.ts."""
94100 if not isinstance(raw, str):
95101 return None
96− s = raw.translate({ord(q): None for q in QUOTES})
102+ s = _QUOTE.sub("", raw)
97103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
98104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
99105 words = words[:4]
100− while len(words) > 2 and len(" ".join(words)) > 28:
106+ if _no_space(" ".join(words)):
107+ while len(words) > 1 and _width(" ".join(words)) > 16:
108+ words.pop()
109+ if _width(" ".join(words)) > 16:
110+ words = [_cut_to_width(" ".join(words), 16)]
111+ else:
112+ while len(words) > 2 and len(" ".join(words)) > 28:
113+ words.pop()
114+ while len(words) > 1 and words[-1].lower() in DANGLING:
101115 words.pop()
102116 label = TRAILING_PUNCT.sub("", " ".join(words))
103117 if not label:
104118 return None
105− label = label[0].upper() + label[1:]
119+ # "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
120+ if not any(c.isupper() for c in words[0][1:]):
121+ label = label[0].upper() + label[1:]
106122 return label if is_valid_label(label) else None
107123
108124
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.
130+CJK = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")
131+
132+
133+def _no_space(label):
134+ return bool(NO_SPACE.search(label))
135+
136+
137+def _width(label):
138+ wide = len(NO_SPACE.findall(label))
139+ return wide + (len(label) - wide) / 2
140+
141+
142+def _cut_to_width(label, most):
143+ out = ""
144+ for ch in label:
145+ if _width(out + ch) > most:
146+ break
147+ out += ch
148+ return out.strip()
149+
150+
109151def is_valid_label(label):
110152 words = label.split()
153+ wide = _no_space(label)
111154 return (
112− 3 <= len(label) <= 28
113− and 2 <= len(words) <= 4
114− and not any(q in label for q in QUOTES)
155+ (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
156+ and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
157+ and not _QUOTE.search(label)
115158 and not TRAILING_PUNCT.search(label)
116159 and not any(w.lower() in BANNED_WORDS for w in words)
117160 )
118161
119162
163+def same_label(a, b):
164+ return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165+
166+
120167def validate_name(out, payload):
121168 if not isinstance(out, dict):
122169 raise ValidationError("reply is not an object")
@@ -164,7 +211,12 @@ def validate_organize(out, payload):
164211 if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
165212 ex = ex[0] if len(ex) == 1 else None
166213 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
167− joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
214+ # Two topics can both point at one group (its example tabs got different topics): one join each.
215+ join = next((j for j in joins if j["existing"] == ex), None)
216+ if join:
217+ join["members"].extend(members)
218+ else:
219+ joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
168220 continue
169221 new.append((members, g))
170222
@@ -217,16 +269,52 @@ def organize_from_topics(out, payload, owner):
217269 votes = group_votes.setdefault(topic, {})
218270 votes[owner[i]] = votes.get(owner[i], 0) + 1
219271 if not tab_topic:
220− raise ValidationError("no tab got a 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)
221276 clusters = {}
222277 for i in sorted(tab_topic):
223278 clusters.setdefault(tab_topic[i], []).append(i)
224279 groups = []
225280 for topic, members in clusters.items():
226− votes = group_votes.get(topic)
227− if votes:
228− g = max(sorted(votes), key=lambda k: votes[k])
281+ g = _winner(group_votes.get(topic) or {}, members, payload)
282+ if g is not None:
229283 groups.append({"existing": g, "members": members})
230284 else:
231285 groups.append({"title": "", "emoji": "", "color": "", "members": members})
232286 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+11−2
@@ -53,7 +53,16 @@ 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−"$BIN" --register "$BIN"
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
5766
5867FM="${DIAGONAL_FM:-/usr/bin/fm}"
5968
@@ -89,7 +98,7 @@ fi
8998# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
9099schemas_out=$("$BIN" --install-schemas 2>&1) || true
91100if selftest_out=$("$BIN" --selftest 2>&1); then
92− echo "Diagonal's host is ready."
101+ [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
93102else
94103 echo "Diagonal's host is installed, but its self-test found a problem:"
95104 echo "$schemas_out"
src/background/engine.ts+136−5
@@ -1,8 +1,9 @@
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";
45import type { Settings } from "./settings";
5−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
6+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
67
78/**
89 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -50,7 +51,8 @@ export type Action =
5051 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
5152 | { type: "dirty"; groupId: number }
5253 | { type: "loose"; windowId: number }
53− | { type: "unpark"; tabId: number; groupId: number };
54+ | { type: "unpark"; tabId: number; groupId: number }
55+ | { type: "checkFit" };
5456
5557export interface Ctx {
5658 now: number;
@@ -60,6 +62,10 @@ export interface Ctx {
6062export const DISSOLVE_DELAY_MS = 1500;
6163export const OWN_CREATE_WINDOW_MS = 2000;
6264export 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;
6369
6470/** The pure form: never touches `prev`. */
6571export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -84,8 +90,16 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
8490const handlers: Handlers = {
8591 tabCreated(s, { tab, opener }, ctx, out) {
8692 if (tab.incognito) return;
93+ const known = !!s.tabs[tab.id];
8794 const rec = upsertTab(s, tab, ctx.now);
88− if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
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+ }
89103 const before = out.length;
90104 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
91105 if (out.length === before) loose(rec, ctx, out);
@@ -100,11 +114,33 @@ const handlers: Handlers = {
100114 else loose(rec, ctx, out);
101115 return;
102116 }
103− const titleChanged = before.title !== rec.title;
117+ const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
104118 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
105119 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
106120 if (before.groupId !== rec.groupId) {
107121 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+ }
130+ rec.fitPending = undefined;
131+ if (s.groups[before.groupId]?.origin === "tidy") rec.parkedAt = undefined; // out of Parked
132+ if (rec.groupId !== -1) {
133+ rec.keepLoose = undefined; // in a group now: there is no loose choice left to protect
134+ // Into a group: ours if the worker put it there, otherwise the user did and it stays.
135+ if (s.ownAdds[rec.id] !== undefined) {
136+ delete s.ownAdds[rec.id];
137+ rec.handPlaced = undefined;
138+ } else {
139+ rec.handPlaced = true;
140+ }
141+ } else {
142+ rec.handPlaced = undefined;
143+ }
108144 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
109145 else if (before.groupId !== -1) {
110146 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
@@ -122,6 +158,10 @@ const handlers: Handlers = {
122158 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
123159 return;
124160 }
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;
164+ if (rec.fitPending && rec.status === "complete") out.push({ type: "checkFit" });
125165 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
126166 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
127167 // A member finished loading: the naming loop may have been waiting for it.
@@ -140,9 +180,12 @@ const handlers: Handlers = {
140180 const rec = s.tabs[tabId];
141181 if (!rec) return;
142182 rec.lastActivatedAt = ctx.now;
183+ // A tab waiting for its fit check is checked once the user has moved on from it.
184+ if (Object.values(s.tabs).some((t) => t.fitPending && t.id !== tabId)) out.push({ type: "checkFit" });
143185 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
144186 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
145187 rec.parkedFrom = undefined;
188+ rec.parkedAt = undefined;
146189 out.push({ type: "unpark", tabId, groupId: rec.groupId });
147190 }
148191 },
@@ -157,6 +200,15 @@ const handlers: Handlers = {
157200 registerGroup(s, group, ctx, out);
158201 return;
159202 }
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+ }
160212 rec.windowId = group.windowId;
161213 const own = s.ownWrites[group.id];
162214 const title = group.title ?? "";
@@ -184,9 +236,11 @@ const handlers: Handlers = {
184236 }
185237 },
186238
187− groupRemoved(s, { groupId }) {
239+ groupRemoved(s, { groupId }, ctx) {
240+ const rec = s.groups[groupId];
188241 delete s.groups[groupId];
189242 delete s.ownWrites[groupId];
243+ if (rec) bury(s, rec, ctx.now);
190244 },
191245
192246 meta(s, { tabId, description }, ctx, out) {
@@ -236,6 +290,21 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
236290export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
237291 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
238292
293+/** Only groups Diagonal made and still names itself, and only tabs it put there, are checked for fit. */
294+export function fitCheckable(s: State, rec: TabRecord, settings: Settings): boolean {
295+ const g = s.groups[rec.groupId];
296+ return (
297+ settings.autoOrganize &&
298+ !!g &&
299+ g.managed &&
300+ (g.origin === "opener" || g.origin === "organize") &&
301+ !g.userNamed &&
302+ !rec.handPlaced &&
303+ !rec.pinned &&
304+ !isInternalUrl(rec.url)
305+ );
306+}
307+
239308/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
240309function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
241310 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
@@ -274,16 +343,64 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
274343 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
275344 return;
276345 }
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+ }
277353 const titled = !!group.title;
278354 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
279355 managed: ctx.settings.nameUserGroups,
280356 userNamed: titled,
281357 stripTitle: group.title ?? "",
358+ registeredAt: ctx.now,
282359 });
283360 s.groups[group.id] = rec;
284361 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
285362}
286363
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+
287404function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
288405 const before = s.tabs[tab.id];
289406 const url = tab.url || tab.pendingUrl || "";
@@ -304,7 +421,12 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
304421 // A description belongs to a page: keep it only while the tab stays on that page.
305422 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
306423 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
424+ if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
425+ if (before?.parkedAt) rec.parkedAt = before.parkedAt;
307426 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
427+ if (before?.organizeRefused) rec.organizeRefused = before.organizeRefused;
428+ if (before?.handPlaced) rec.handPlaced = true;
429+ if (before?.fitPending) rec.fitPending = true;
308430 s.tabs[tab.id] = rec;
309431 return rec;
310432}
@@ -327,4 +449,13 @@ function prune(s: State, now: number): void {
327449 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
328450 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
329451 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
452+ 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+ });
330461}
src/background/fit.tsnew file+122
@@ -0,0 +1,122 @@
1+import { isInternalUrl } from "../shared/url";
2+import { fitCheckable } from "./engine";
3+import { settled, toItems, type Member } from "./naming";
4+import type { OrganizePayload, OrganizeResult } from "./organize";
5+import { ungroup, type Runtime } from "./runtime";
6+
7+/**
8+ * A tab in a group Diagonal made that moves on to an unrelated page leaves the group; auto-organize then
9+ * files it with its new topic. The check asks the host's topic question for the tab and a few groupmates:
10+ * the tab fits when it shares a topic with at least one of them.
11+ */
12+export const FIT_DELAY_MS = 2000;
13+export const FIT_SAMPLE = 5; // groupmates to compare with, most recently used first
14+const MIN_OTHERS = 2; // with fewer, one topic answer decides too much
15+
16+export type FitVerdict = "fits" | "leave" | "unsure";
17+
18+/** Index 0 is the tab being checked, the rest its groupmates. */
19+export function fitVerdict(result: OrganizeResult): FitVerdict {
20+ const withTab = result.groups.find((g) => g.members.includes(0));
21+ if (withTab && withTab.members.some((i) => i !== 0)) return "fits";
22+ // Only move it when its groupmates still agree with each other; otherwise the group itself is mixed.
23+ return result.groups.some((g) => !g.members.includes(0) && g.members.length >= 2) ? "leave" : "unsure";
24+}
25+
26+export class FitChecker {
27+ private timer: ReturnType<typeof setTimeout> | undefined;
28+ private running = false;
29+ private again = false;
30+
31+ constructor(private rt: Runtime) {}
32+
33+ touch(delayMs = FIT_DELAY_MS): void {
34+ if (this.timer) clearTimeout(this.timer);
35+ this.timer = setTimeout(() => {
36+ this.timer = undefined;
37+ void this.run();
38+ }, delayMs);
39+ }
40+
41+ async run(): Promise<void> {
42+ if (this.running) {
43+ this.again = true;
44+ return;
45+ }
46+ this.running = true;
47+ try {
48+ const pending = Object.values(this.rt.state().tabs).filter((t) => t.fitPending).map((t) => t.id);
49+ for (const id of pending) await this.check(id);
50+ } catch (e) {
51+ this.rt.log("fit check failed", e);
52+ } finally {
53+ this.running = false;
54+ if (this.again) {
55+ this.again = false;
56+ this.touch();
57+ }
58+ }
59+ }
60+
61+ private async check(tabId: number): Promise<void> {
62+ const rt = this.rt;
63+ const rec = rt.state().tabs[tabId];
64+ if (!rec?.fitPending) return;
65+ const tab = await chrome.tabs.get(tabId).catch(() => undefined);
66+ const done = () => {
67+ const live = rt.state().tabs[tabId];
68+ if (live) live.fitPending = undefined;
69+ rt.commit();
70+ };
71+ if (!tab || (tab.groupId ?? -1) !== rec.groupId || !fitCheckable(rt.state(), rec, rt.settings())) return done();
72+ // Still loading, or the user is reading it: the load or the next tab switch checks again.
73+ if (tab.status !== "complete" || tab.active) return;
74+ const pausedUntil = rt.state().host.pausedUntil;
75+ if (pausedUntil && pausedUntil > rt.now()) return;
76+
77+ const others = (await chrome.tabs.query({ groupId: rec.groupId }).catch(() => []))
78+ .filter((t) => t.id !== tabId && settled(t) && !isInternalUrl(t.url))
79+ .sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0))
80+ .slice(0, FIT_SAMPLE);
81+ if (others.length < MIN_OTHERS) return done();
82+
83+ const s = rt.state();
84+ const toMember = (t: chrome.tabs.Tab): Member => ({
85+ id: t.id!,
86+ index: t.index,
87+ url: t.url ?? "",
88+ title: t.title ?? "",
89+ status: t.status,
90+ description: s.tabs[t.id!]?.description,
91+ lastActive: t.lastAccessed ?? 0,
92+ });
93+ const url = tab.url ?? "";
94+ const payload: OrganizePayload & { nameGroups: false } = {
95+ items: toItems([tab, ...others].map(toMember), rt.settings()),
96+ existingGroups: [],
97+ allowNew: true,
98+ maxGroups: 8,
99+ minGroupSize: 2,
100+ nameGroups: false, // only the topics matter here
101+ };
102+ const reply = await rt.host<OrganizeResult>("organize", payload);
103+
104+ const live = rt.state().tabs[tabId];
105+ // It moved on again (or out) while fm was thinking: that newer page gets its own check.
106+ if (!live || live.url !== url || live.groupId !== rec.groupId) return;
107+ if (!reply.ok) {
108+ rt.log("fit check", reply.error.code);
109+ return done();
110+ }
111+ const verdict = fitVerdict(reply.result);
112+ done();
113+ if (verdict !== "leave") return;
114+ const current = await chrome.tabs.get(tabId).catch(() => undefined);
115+ if (!current || current.active || current.groupId !== rec.groupId) return;
116+ rt.log("fit check: tab left its group", tabId, rec.groupId);
117+ live.organizedKey = undefined; // auto-organize looks at it again
118+ rt.state().ownUngroups[tabId] = rt.now();
119+ rt.commit();
120+ await ungroup(rt, [tabId]);
121+ }
122+}
src/background/host.ts+3
@@ -102,6 +102,9 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102102 };
103103}
104104
105+/** Errors about what one request carried, not about the host: they don't count toward pausing it. */
106+export const REQUEST_ERRORS = new Set<HostErrorCode>(["GUARDRAIL", "BAD_MODEL_OUTPUT", "OVER_BUDGET", "BAD_REQUEST"]);
107+
105108/** Errors that mean "fix the setup", not "try this group again later". */
106109export const SETUP_ERRORS = new Set<HostErrorCode>([
107110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
src/background/index.ts+128−36
@@ -1,13 +1,14 @@
11import { stripTitle } from "../shared/label";
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";
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";
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";
6+import { FitChecker } from "./fit";
67import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
78import { withDefaults, type Settings } from "./settings";
8−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
9+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
910import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
10−import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
11+import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
1112
1213/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
1314
@@ -19,6 +20,8 @@ const ALARM_HOST = "host-retry";
1920const ALARM_AUTO = "auto-organize";
2021const ALARM_UPDATE = "self-update";
2122const 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();
2225
2326// ----- state cache ------------------------------------------------------------------------------
2427
@@ -90,7 +93,11 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9093 h.lastOkAt = Date.now();
9194 h.consecutiveFailures = 0;
9295 h.consecutiveTimeouts = 0;
93− h.rateLimitStep = 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+ }
94101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
95102 } else {
96103 recordHostError(reply.error);
@@ -104,6 +111,8 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
104111}
105112
106113function 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;
107116 const h = state.host;
108117 const now = Date.now();
109118 h.lastError = { ...e, at: now };
@@ -135,11 +144,7 @@ async function ping(): Promise<HostReply<PingResult>> {
135144 } else if (!reply.result.schemasOk) {
136145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
137146 } else {
138− h.pausedUntil = undefined;
139− h.lastError = undefined;
140− chrome.alarms.clear(ALARM_HOST);
141− void naming.sweepDirty();
142− void autoOrganizer.sweep();
147+ recovered();
143148 }
144149 }
145150 commit();
@@ -147,6 +152,16 @@ async function ping(): Promise<HostReply<PingResult>> {
147152 return reply;
148153}
149154
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+
150165// ----- badge -----------------------------------------------------------------------------------
151166
152167function busy(on: boolean): void {
@@ -158,7 +173,6 @@ function hostUnhealthy(): boolean {
158173 const h = state.host;
159174 const now = Date.now();
160175 if (h.pausedUntil && h.pausedUntil > now) return true;
161− if (h.consecutiveFailures >= 3) return true;
162176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
163177}
164178
@@ -239,6 +253,7 @@ async function scheduleAutoFallback(when: number): Promise<void> {
239253}
240254
241255const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
256+const fitChecker = new FitChecker(rt);
242257
243258// ----- engine glue -----------------------------------------------------------------------------
244259
@@ -327,6 +342,9 @@ async function run(a: Action): Promise<void> {
327342 case "dirty":
328343 naming.touch(a.groupId);
329344 break;
345+ case "checkFit":
346+ fitChecker.touch();
347+ break;
330348 }
331349}
332350
@@ -341,16 +359,24 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
341359 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
342360 * (groups), which keeps managed groups managed after the browser restarts.
343361 */
344−async function reconcile(): Promise<void> {
362+async function reconcile(reason: "startup" | "installed"): Promise<void> {
345363 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
346364 const now = Date.now();
347− const oldTabs = Object.values(state.tabs);
348− const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
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]));
349368 const nextTabs: Record<number, TabRecord> = {};
350369 for (const t of tabs) {
351370 if (t.incognito || t.id === undefined) continue;
352371 const url = t.url || t.pendingUrl || "";
353− const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
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);
354380 nextTabs[t.id] = {
355381 id: t.id,
356382 windowId: t.windowId,
@@ -363,24 +389,51 @@ async function reconcile(): Promise<void> {
363389 openerTabId: t.openerTabId,
364390 description: prev?.description,
365391 parkedFrom: prev?.parkedFrom,
392+ parkedAt: prev?.parkedAt,
366393 keepLoose: prev?.keepLoose,
367394 organizedKey: prev?.organizedKey,
395+ organizeRefused: prev?.organizeRefused,
396+ handPlaced: prev?.handPlaced,
397+ fitPending: prev?.fitPending,
368398 createdAt: prev?.createdAt ?? now,
369− lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
399+ lastActivatedAt: lastSeen || now,
370400 };
371401 }
402+ const liveGroupIds = new Set(groups.map((g) => g.id));
372403 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+ };
373417 const nextGroups: Record<number, GroupRecord> = {};
374418 for (const g of groups) {
375419 const title = g.title ?? "";
376− let prev = unmatched.get(g.id);
377− if (!prev || (prev.stripTitle ?? "") !== title) {
378− prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
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 });
379434 }
380− if (prev) unmatched.delete(prev.id);
381− const rec: GroupRecord = prev
382− ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
383− : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
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);
384437 if (rec.managed) {
385438 rec.dirty = true;
386439 rec.dirtyAt = 0;
@@ -388,13 +441,17 @@ async function reconcile(): Promise<void> {
388441 }
389442 nextGroups[g.id] = rec;
390443 }
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);
391446 state.tabs = nextTabs;
392447 state.groups = nextGroups;
393448 state.pendingCreates = [];
394449 state.ownWrites = {};
395450 state.ownUngroups = {};
396− if (state.lastSweep && now - state.lastSweep.at > SWEEP_UNDO_MS) state.lastSweep = undefined;
397− if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
451+ 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;
398455 commit();
399456}
400457
@@ -407,9 +464,13 @@ async function injectBacklog(): Promise<void> {
407464 }
408465}
409466
467+let reconciled: Promise<void> | undefined;
468+
410469async function boot(reason: "startup" | "installed"): Promise<void> {
411− await ready;
412− await serial("reconcile", reconcile);
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;
413474 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
414475 if (reason === "installed") void injectBacklog();
415476 await ping();
@@ -451,20 +512,29 @@ if (GROUPS_SUPPORTED) {
451512
452513 chrome.tabs.onReplaced.addListener((added, removed) =>
453514 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.
454517 const rec = state.tabs[removed];
455518 delete state.tabs[removed];
456519 const t = await tabById(added);
457520 if (t) {
521+ if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
458522 await feed({ type: "tabUpdated", tab: snapTab(t) });
459− if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
460523 }
461524 }),
462525 );
463526
464527 chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
465528
466− chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
467− chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
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"));
468538 chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
469539}
470540
@@ -493,7 +563,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
493563 await ready;
494564 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
495565 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
496− else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
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+ }
497570 else if (alarm.name === ALARM_HOST) {
498571 const r = await ping();
499572 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -507,27 +580,46 @@ chrome.storage.onChanged.addListener((changes, area) => {
507580 await ready;
508581 const before = settings;
509582 settings = withDefaults(changes.settings.newValue);
510− if (before.emoji !== settings.emoji) await rewriteTitles();
583+ if (before.emoji !== settings.emoji) await rewriteTitles(before);
584+ if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
511585 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
512586 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
513587 refreshBadge();
514588 })();
515589});
516590
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+
517604/** Emoji switched on or off: rewrite the titles the extension wrote. */
518−async function rewriteTitles(): Promise<void> {
605+async function rewriteTitles(before: Settings): Promise<void> {
519606 for (const g of Object.values(state.groups)) {
520− if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
521− else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
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) });
522611 }
523612}
524613
525614async function nameGroupNow(groupId: number): Promise<void> {
526615 const g = state.groups[groupId];
527616 if (!g) return;
528− // An explicit request overrides "don't name" and a hand-typed title for this group.
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.
529619 g.managed = true;
530620 g.userNamed = false;
621+ g.title = undefined;
622+ g.lastNamedAt = undefined;
531623 commit();
532624 await naming.nameNow(groupId);
533625}
src/background/naming.ts+85−27
@@ -1,6 +1,7 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
45import type { HostOpts, HostReply } from "./host";
56import { SETUP_ERRORS } from "./host";
67import type { Settings } from "./settings";
@@ -49,12 +50,18 @@ export interface NameResult {
4950 emoji: string;
5051}
5152
52−/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
53+/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
5354export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
5455
56+/**
57+ * Done loading, or not loaded at all: a tab the browser discarded to save memory (status "unloaded") keeps
58+ * its title and address, so it can describe its group without being loaded again.
59+ */
60+export const settled = (t: { status?: string }): boolean => t.status !== "loading";
61+
5562/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
5663export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
57− sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
64+ sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
5865
5966export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
6067
@@ -107,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
107114 if (g.userNamed) return false;
108115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
109116 if (sameLabel(label, g.title)) return false;
110− const before = new Set(g.memberUrls ?? []);
111− const after = new Set(urls);
117+ // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
119+ const after = new Set(urls.map(pathKey));
112120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
113121 return changed; // a paraphrase with the same members keeps the old title
114122}
@@ -208,18 +216,32 @@ export class Naming {
208216 }
209217 const now = this.d.now();
210218 if (!force) {
211− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
219+ // A refusal waits for the members to change, which only a look at them can tell.
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
212221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
213222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
214223 }
215224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
216225 if (!members) return;
217− if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
218− if (!force && membersHash(members) === g.membersHash) {
219− g.dirty = false;
226+ if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
227+ if (members.length < 2) {
228+ // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
229+ if (g.dirty && !force) {
230+ g.dirty = false;
231+ this.d.commit();
232+ }
233+ return;
234+ }
235+ const hash = membersHash(members);
236+ if (!force && hash === g.membersHash) {
237+ settle(g);
220238 this.d.commit();
221239 return;
222240 }
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+ }
223245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
224246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
225247 void this.pump();
@@ -252,10 +274,10 @@ export class Naming {
252274 if (!g || !eligible(g, settings)) return;
253275 // Built lazily: a group that changed three times while queued is described once.
254276 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
255− if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
277+ if (!members || members.length < 2 || !members.every(settled)) return;
256278 const hash = membersHash(members);
257279 if (!force && hash === g.membersHash) {
258− g.dirty = false;
280+ settle(g);
259281 this.d.commit();
260282 return;
261283 }
@@ -290,9 +312,10 @@ export class Naming {
290312 }
291313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
292314 retried.output = true;
315+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
293316 continue;
294317 }
295− this.fail(g, e);
318+ this.fail(g, e, hash);
296319 return;
297320 }
298321
@@ -301,9 +324,10 @@ export class Naming {
301324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
302325 if (!retried.output) {
303326 retried.output = true;
327+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
304328 continue;
305329 }
306− this.fail(g, e);
330+ this.fail(g, e, hash);
307331 return;
308332 }
309333 const emoji = safeEmoji(reply.result.emoji);
@@ -318,12 +342,15 @@ export class Naming {
318342 finalLabel = suffixLabel(label, siblings);
319343 }
320344 this.d.hostOk();
321− await this.apply(g, finalLabel, emoji, members, hash);
345+ const written = await this.apply(g, finalLabel, emoji, members, hash);
322346 g = this.d.state().groups[groupId];
323− if (g) {
324− if (g.dirtySeq === seq) g.dirty = false;
325− else this.touch(groupId); // dirtied while in flight: go again with the new membership
326− }
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
327354 return;
328355 }
329356 } finally {
@@ -334,16 +361,20 @@ export class Naming {
334361 }
335362 }
336363
337− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
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> {
338366 const settings = this.d.settings();
339367 const urls = memberUrls(members);
340− if (g.userNamed) return; // renamed by hand while the model was thinking
368+ if (g.userNamed) return true; // renamed by hand while the model was thinking
341369 if (shouldApply(g, label, urls)) {
342370 const title = stripTitle(label, emoji, settings.emoji);
343371 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
344372 this.d.commit();
345373 const ok = await this.d.writeTitle(g.id, title);
346− if (!ok) return;
374+ if (!ok) {
375+ delete this.d.state().ownWrites[g.id];
376+ return false;
377+ }
347378 g.title = label;
348379 g.emoji = emoji;
349380 g.stripTitle = title;
@@ -351,21 +382,24 @@ export class Naming {
351382 g.lastNamedAt = this.d.now();
352383 g.membersHash = hash;
353384 g.memberUrls = urls;
354− g.nameAttempts = 0;
355− g.firstFailureAt = undefined;
356− g.nextAttemptAt = undefined;
385+ clearFailures(g);
386+ return true;
357387 }
358388
359− private fail(g: GroupRecord, e: HostError): void {
389+ private fail(g: GroupRecord, e: HostError, hash: string): void {
360390 const now = this.d.now();
361391 this.d.hostFailed(e);
362392 if (e.code === "BAD_REQUEST") {
363393 g.dirty = false; // a bug on our side: log and drop
364394 return;
365395 }
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;
366399 if (e.code === "GUARDRAIL") {
367400 g.nameAttempts = MAX_ATTEMPTS;
368401 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402+ g.refusedHash = hash;
369403 this.d.scheduleFallback(g.nextAttemptAt);
370404 return;
371405 }
@@ -375,11 +409,35 @@ export class Naming {
375409 g.dirty = false; // retried for a day: keep the provisional title
376410 return;
377411 }
378− const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
379− g.nextAttemptAt = now + wait;
412+ g.nextAttemptAt = now + backoffFor(g.nameAttempts);
380413 this.d.scheduleFallback(g.nextAttemptAt);
381414 }
382415}
383416
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+
384442export const eligible = (g: GroupRecord, settings: Settings): boolean =>
385443 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+204−111
@@ -1,16 +1,19 @@
11import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
33import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
44import { pathKey } from "./engine";
5−import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
66import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7−import { markDirty, type State } from "./state";
7+import type { HostReply } from "./host";
8+import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
89
910/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1011
1112export const ORGANIZE_ITEM_CAP = 36;
1213export const EXISTING_GROUPS_CAP = 12;
13−export const CHAR_BUDGET = 24_000; // matches the host's budget for fm's ~8k-token context
14+// About the host's 7,000-token budget in English characters. Chinese and Japanese cost about twice the tokens
15+// per character, so their characters count double (weightedLength); the host measures exactly and shortens.
16+export const CHAR_BUDGET = 24_000;
1417export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
1518export const EXISTING_GROUP_CHARS = 90;
1619export const UNDO_WINDOW_MS = 3_600_000;
@@ -24,6 +27,8 @@ export interface ExistingGroup {
2427export interface OrganizePayload {
2528 items: NameItem[];
2629 existingGroups: ExistingGroup[];
30+ /** Every group label in the window: a new group the host names must not repeat one. */
31+ siblingTitles?: string[];
2732 allowNew: boolean;
2833 maxGroups: number;
2934 minGroupSize: number;
@@ -53,9 +58,12 @@ export const maxGroupsFor = (n: number): number => Math.min(8, Math.ceil(n / 3))
5358export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
5459 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
5560
61+const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/g;
62+export const weightedLength = (s: string): number => s.length + (s.match(CJK)?.length ?? 0);
63+
5664/** Rough rendered size of one item line, the unit the character budget is spent in. */
5765export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
58− Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(500, m.description?.length ?? 1) + 12;
66+ weightedLength(m.title.slice(0, 120)) + promptAddress(m.url).length + weightedLength((m.description ?? " ").slice(0, 500)) + 12;
5967
6068/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
6169export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
@@ -146,88 +154,198 @@ export interface OrganizeReport {
146154 error?: string;
147155}
148156
149−/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
157+/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
150158export 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));
151163 const groups = Object.values(state.groups)
152− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
153− .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
154− .slice(0, EXISTING_GROUPS_CAP);
164+ .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && !g.restored && (g.stripTitle || g.title))
165+ .map((g) => ({ g, first: extraIds.includes(g.id), at: inUse(g) }))
166+ .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
167+ .slice(0, EXISTING_GROUPS_CAP)
168+ .map(({ g }) => g);
155169 const list = groups.map((g, i) => ({
156170 g: i,
157171 title: labelOf(g.stripTitle || g.title),
158− samples: Object.values(state.tabs)
159− .filter((t) => t.groupId === g.id)
160− .slice(0, 2)
161− .map((t) => t.title.slice(0, 60)),
172+ samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
162173 }));
163174 return { ids: groups.map((g) => g.id), list };
164175}
165176
166−export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
177+/** Errors about what was sent rather than about the host: a smaller or plainer request may go through. */
178+const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_MODEL_OUTPUT"]);
179+const MAX_TIMEOUT_SPLITS = 1;
180+
181+/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
182+async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
183+ const settings = rt.settings();
184+ const s = rt.state();
185+ const payload: OrganizePayload = {
186+ items: toItems(part, settings, withDescriptions),
187+ existingGroups,
188+ siblingTitles: Object.values(s.groups)
189+ .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190+ .map((g) => labelOf(g.stripTitle || g.title))
191+ .filter(Boolean)
192+ .slice(0, 20),
193+ allowNew: part.length >= 2,
194+ maxGroups: maxGroupsFor(part.length),
195+ minGroupSize: settings.organizeMinGroupSize,
196+ };
197+ let reply = await rt.host<OrganizeResult>("organize", payload);
198+ if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
199+ if (!reply.ok && reply.error.code === "GUARDRAIL" && payload.items.some((i) => i.description)) {
200+ // What a page says is the likeliest thing to trip Apple's filter; its title and address rarely do.
201+ payload.items = toItems(part, settings, false);
202+ reply = await rt.host<OrganizeResult>("organize", payload);
203+ }
204+ return reply;
205+}
206+
207+/** One organize run over a window: what it made, what it moved, and whether the host stopped it. */
208+export interface SortRun {
209+ windowId: number;
210+ createdIds: number[];
211+ previous: Record<number, number>;
212+ grouped: number;
213+ left: number;
214+ timeoutSplits: number;
215+ /** Auto-organize: remember what was looked at, so the same tabs are not sent again unchanged. */
216+ stamp: boolean;
217+ stopped?: HostError;
218+}
219+
220+export const newRun = (windowId: number, stamp: boolean): SortRun => ({ windowId, createdIds: [], previous: {}, grouped: 0, left: 0, timeoutSplits: 0, stamp });
221+
222+/**
223+ * Sort one part of a window. A reply about the tabs themselves (too long, refused by Apple's filter, not
224+ * valid output) splits the part in halves, so one page the model won't handle leaves the others sorted; a
225+ * tab that fails alone is set aside until its page or title changes. Tabs that changed while the model was
226+ * thinking are left out of its answer.
227+ */
228+export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe = false): Promise<void> {
229+ if (!part.length || run.stopped) return;
230+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), run.windowId, run.createdIds);
231+ if (part.length < 2 && !existingIds.length && !probe) {
232+ run.left += part.length;
233+ return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
234+ }
235+ const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
236+ if (!reply.ok) {
237+ const e = reply.error;
238+ const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
239+ if (part.length > 1 && (CONTENT_ERRORS.has(e.code) || timedOut)) {
240+ if (timedOut) run.timeoutSplits++;
241+ const half = Math.ceil(part.length / 2);
242+ await sortPart(rt, part.slice(0, half), run, true);
243+ await sortPart(rt, part.slice(half), run, true);
244+ return;
245+ }
246+ if (CONTENT_ERRORS.has(e.code)) {
247+ const rec = rt.state().tabs[part[0].id];
248+ if (rec) rec.organizeRefused = organizedKeyOf(part[0]);
249+ rt.log("organize: set aside a tab the model would not sort", e.code);
250+ run.left += 1;
251+ return;
252+ }
253+ run.stopped = e;
254+ return;
255+ }
167256 const settings = rt.settings();
257+ const plan = planFromResult(reply.result, part.map((m) => m.id), existingIds, settings.organizeMinGroupSize, maxGroupsFor(part.length), (ids) =>
258+ colorFor(part.find((m) => m.id === ids[0])?.url ?? ""),
259+ );
260+ const unchanged = await stillAsSent(rt, part, run.windowId);
261+ const trimmed = trimPlan(rt.state(), plan, unchanged, settings.organizeMinGroupSize);
262+ const failed = await applyPlan(rt, trimmed, run.windowId, part, run.previous, run.createdIds);
263+ const placed = [...trimmed.joins, ...trimmed.creates].reduce((n, x) => n + x.tabIds.filter((id) => !failed.has(id)).length, 0);
264+ run.grouped += placed;
265+ run.left += part.length - placed;
266+ if (run.stamp) {
267+ const s = rt.state();
268+ for (const m of part) {
269+ const rec = s.tabs[m.id];
270+ if (rec && unchanged.has(m.id) && !failed.has(m.id)) rec.organizedKey = organizedKeyOf(m);
271+ }
272+ }
273+}
274+
275+/** The tabs of a part that are still loose, in this window, on the page and title the model saw. */
276+async function stillAsSent(rt: Runtime, part: Member[], windowId: number): Promise<Set<number>> {
277+ const live = new Map((await chrome.tabs.query({ windowId }).catch(() => [])).map((t) => [t.id!, t]));
278+ const s = rt.state();
279+ return new Set(
280+ part
281+ .filter((m) => {
282+ const t = live.get(m.id);
283+ return !!t && organizable(t) && !s.tabs[m.id]?.keepLoose && organizedKeyOf(t) === organizedKeyOf(m);
284+ })
285+ .map((m) => m.id),
286+ );
287+}
288+
289+/** A plan with the tabs that moved on taken out: a new group left too small is not made, a join into a group gone is dropped. */
290+export function trimPlan(state: State, plan: Plan, keep: Set<number>, minGroupSize: number): Plan {
291+ const out: Plan = { joins: [], creates: [], leftovers: plan.leftovers.filter((id) => keep.has(id)) };
292+ for (const j of plan.joins) {
293+ const tabIds = j.tabIds.filter((id) => keep.has(id));
294+ const g = state.groups[j.groupId];
295+ if (tabIds.length && g?.managed && !g.restored) out.joins.push({ ...j, tabIds });
296+ else out.leftovers.push(...tabIds);
297+ }
298+ for (const c of plan.creates) {
299+ const tabIds = c.tabIds.filter((id) => keep.has(id));
300+ if (tabIds.length >= Math.max(2, minGroupSize)) out.creates.push({ ...c, tabIds });
301+ else out.leftovers.push(...tabIds);
302+ }
303+ return out;
304+}
305+
306+export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307+ const s0 = rt.state();
168308 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
169309 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
170− if (tabs.length < 2) {
310+ // A tab the model already would not sort, on the same page, is not sent again.
311+ const sendable = tabs.filter((t) => s0.tabs[t.id!]?.organizeRefused !== organizedKeyOf(t));
312+ if (sendable.length < 2) {
171313 report.left = tabs.length;
172314 return report;
173315 }
174− const members: Member[] = tabs.map((t) => ({
175− id: t.id!,
176− index: t.index,
177− url: t.url ?? "",
178− title: t.title ?? "",
179− status: t.status,
180− description: rt.state().tabs[t.id!]?.description,
181− lastActive: t.lastAccessed ?? 0,
182− }));
183− const previous: Record<number, number> = {};
184− const createdIds: number[] = [];
185− const queue = makeBatches(members);
316+ const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
186317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
187318 rt.refreshBadge();
188319 try {
189− while (queue.length) {
190− const batch = queue.shift()!;
191− const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), windowId, createdIds);
192− const maxGroups = maxGroupsFor(batch.length);
193− const payload: OrganizePayload = {
194− items: toItems(batch, settings),
195− existingGroups,
196− allowNew: true,
197− maxGroups,
198− minGroupSize: settings.organizeMinGroupSize,
199− };
200− let reply = await rt.host<OrganizeResult>("organize", payload);
201− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
202− const half = Math.ceil(batch.length / 2);
203− queue.unshift(batch.slice(0, half), batch.slice(half));
204− continue;
205− }
206− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
207− if (!reply.ok) {
208− report.error = `${reply.error.code}: ${reply.error.message}`;
209− report.left += batch.length + queue.flat().length;
210− break;
211− }
212− const batchIds = batch.map((m) => m.id);
213− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, maxGroups, (ids) =>
214− colorFor(members.find((m) => m.id === ids[0])?.url ?? ""),
215− );
216− await applyPlan(rt, plan, windowId, members, previous, createdIds);
217− report.grouped += plan.joins.reduce((n, j) => n + j.tabIds.length, 0) + plan.creates.reduce((n, c) => n + c.tabIds.length, 0);
218− report.left += plan.leftovers.length;
320+ for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
321+ await sortPart(rt, batch, run);
322+ if (run.stopped) break;
219323 }
220324 } finally {
221325 const s = rt.state();
222326 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
223− report.groups = createdIds.length;
224− if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
327+ report.grouped = run.grouped;
328+ report.groups = run.createdIds.length;
329+ report.left = tabs.length - run.grouped;
330+ if (run.stopped) report.error = `${run.stopped.code}: ${run.stopped.message}`;
331+ if (Object.keys(run.previous).length) s.lastOrganize = { at: rt.now(), previous: run.previous };
225332 rt.commit();
226333 rt.refreshBadge();
227334 }
228335 return report;
229336}
230337
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. */
231349export async function applyPlan(
232350 rt: Runtime,
233351 plan: Plan,
@@ -235,11 +353,15 @@ export async function applyPlan(
235353 members: Member[],
236354 previous: Record<number, number>,
237355 createdIds: number[],
238−): Promise<void> {
356+): Promise<Set<number>> {
239357 const settings = rt.settings();
240358 const byId = new Map(members.map((m) => [m.id, m]));
359+ const failed = new Set<number>();
241360 for (const j of plan.joins) {
242− if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
361+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
362+ for (const id of j.tabIds) failed.add(id);
363+ continue;
364+ }
243365 for (const id of j.tabIds) previous[id] = -1;
244366 const g = rt.state().groups[j.groupId];
245367 if (g) {
@@ -260,7 +382,10 @@ export async function applyPlan(
260382 lastNamedAt: named ? rt.now() : undefined,
261383 dirty: false,
262384 });
263− if (groupId === undefined) continue;
385+ if (groupId === undefined) {
386+ for (const id of c.tabIds) failed.add(id);
387+ continue;
388+ }
264389 createdIds.push(groupId);
265390 for (const id of c.tabIds) previous[id] = -1;
266391 const g = rt.state().groups[groupId];
@@ -269,6 +394,7 @@ export async function applyPlan(
269394 rt.naming.touch(groupId);
270395 }
271396 }
397+ return failed;
272398}
273399
274400/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -296,7 +422,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
296422}
297423
298424/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
299−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
425+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
300426
301427export const AUTO_RETRY_MS = 300_000;
302428export const AUTO_CONTINUE_MS = 1_500;
@@ -372,60 +498,27 @@ export class AutoOrganizer {
372498 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
373499 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
374500 const s = rt.state();
375− // Still-loading tabs are skipped here; finishing the load touches the window again.
376− const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
501+ // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
502+ // 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+ });
377507 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
378508 if (!fresh.length) return;
379− const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
380− if (loose.length < 2 && !existingIds.length) return; // one loose tab and nothing to join: wait for company
381− const toMember = (t: chrome.tabs.Tab): Member => ({
382− id: t.id!,
383− index: t.index,
384− url: t.url ?? "",
385− title: t.title ?? "",
386− status: t.status,
387− description: s.tabs[t.id!]?.description,
388− lastActive: t.lastAccessed ?? 0,
389− });
390− const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map(toMember);
509+ if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.length) return; // one loose tab and nothing to join: wait for company
510+ const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
391511 const batch = makeBatches(ordered)[0];
392− const maxGroups = maxGroupsFor(batch.length);
393− const payload: OrganizePayload = {
394− items: toItems(batch, settings),
395− existingGroups,
396− allowNew: batch.length >= 2,
397− maxGroups,
398− minGroupSize: settings.organizeMinGroupSize,
399− };
400− let reply = await rt.host<OrganizeResult>("organize", payload);
401− if (!reply.ok && reply.error.code === "OVER_BUDGET" && batch.length > 1) {
402− // Rare with the budgeted batch; try once with half, the rest follows on the next run.
403− const half = batch.slice(0, Math.ceil(batch.length / 2));
404− payload.items = toItems(half, settings);
405− payload.maxGroups = maxGroupsFor(half.length);
406− payload.allowNew = half.length >= 2;
407− batch.length = half.length;
408− reply = await rt.host<OrganizeResult>("organize", payload);
409− }
410− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
411− if (!reply.ok) {
412− rt.log("auto-organize", reply.error.code);
512+ const run = newRun(windowId, true);
513+ await sortPart(rt, batch, run);
514+ rt.commit();
515+ if (run.stopped) {
516+ rt.log("auto-organize", run.stopped.code);
413517 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
414518 return;
415519 }
416− const batchIds = batch.map((m) => m.id);
417− const plan = planFromResult(reply.result, batchIds, existingIds, settings.organizeMinGroupSize, payload.maxGroups, (ids) =>
418− colorFor(batch.find((m) => m.id === ids[0])?.url ?? ""),
419− );
420− await applyPlan(rt, plan, windowId, batch, {}, []);
421− const live = rt.state();
422− for (const m of batch) {
423− const rec = live.tabs[m.id];
424− if (rec) rec.organizedKey = organizedKeyOf(m);
425− }
426− rt.commit();
427− const remaining = fresh.filter((t) => !batchIds.includes(t.id!));
428− if (remaining.length) this.touch(windowId, AUTO_CONTINUE_MS);
520+ const sent = new Set(batch.map((m) => m.id));
521+ if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
429522 }
430523}
431524
src/background/runtime.ts+16−2
@@ -67,34 +67,48 @@ export async function createManagedGroup(
6767 extra: Partial<GroupRecord> = {},
6868): Promise<number | undefined> {
6969 const s = rt.state();
70− s.pendingCreates.push({ windowId, origin, at: rt.now() });
70+ const pending = { windowId, origin, at: rt.now() };
71+ s.pendingCreates.push(pending);
72+ for (const id of tabIds) s.ownAdds[id] = rt.now();
7173 let groupId: number;
7274 try {
7375 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
7476 } catch (e) {
7577 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);
7681 return undefined;
7782 }
7883 const live = rt.state();
7984 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
8085 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
8186 live.groups[groupId] = rec;
82− for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
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.
8389 await updateGroup(rt, groupId, props);
8490 rt.commit();
8591 return groupId;
8692}
8793
8894export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
95+ const s = rt.state();
96+ for (const id of tabIds) s.ownAdds[id] = rt.now();
8997 try {
9098 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
9199 return true;
92100 } catch (e) {
93101 rt.log("tabs.group into existing failed", e);
102+ forgetOwnAdds(rt.state(), tabIds);
94103 return false;
95104 }
96105}
97106
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+
98112export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
99113 if (!tabIds.length) return;
100114 try {
src/background/state.ts+24
@@ -18,8 +18,12 @@ 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
2122 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
2223 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
25+ handPlaced?: boolean; // the user put this tab into its group: Diagonal won't take it out
26+ fitPending?: boolean; // moved to a new page inside a Diagonal group: check it still fits (fit.ts)
2327}
2428
2529export interface GroupRecord {
@@ -43,8 +47,15 @@ export interface GroupRecord {
4347 nameAttempts: number;
4448 firstFailureAt?: number;
4549 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
4654}
4755
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+
4859export interface ArchivedTab {
4960 url: string;
5061 title: string;
@@ -105,6 +116,11 @@ export interface SweepMove {
105116 groupId: number;
106117 index: number;
107118 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+ >;
108124}
109125
110126export interface State {
@@ -120,6 +136,10 @@ export interface State {
120136 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
121137 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
122138 ownUngroups: Record<number, number>;
139+ /** Tabs the worker itself is adding to a group: joining it is not the user's choice. */
140+ ownAdds: Record<number, number>;
141+ /** Groups Chromium removed lately, by their last id. */
142+ removedGroups: Record<number, RemovedGroup>;
123143 lastSweep?: { at: number; moves: SweepMove[] };
124144 lastOrganize?: { at: number; previous: Record<number, number> };
125145 tidyCandidates?: number;
@@ -134,6 +154,8 @@ export const emptyState = (): State => ({
134154 ownWrites: {},
135155 pendingCreates: [],
136156 ownUngroups: {},
157+ ownAdds: {},
158+ removedGroups: {},
137159});
138160
139161/** Accept whatever was stored and bring it to the current shape. */
@@ -154,6 +176,8 @@ export function migrate(raw: unknown): State {
154176 ownWrites: r.ownWrites ?? {},
155177 pendingCreates: r.pendingCreates ?? [],
156178 ownUngroups: r.ownUngroups ?? {},
179+ ownAdds: r.ownAdds ?? {},
180+ removedGroups: r.removedGroups ?? {},
157181 };
158182}
159183
src/background/tidy.ts+129−17
@@ -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 type { ArchivedTab, State, SweepMove } from "./state";
6+import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
77
88/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
99
@@ -23,11 +23,22 @@ export interface TidyTab {
2323 audible?: boolean;
2424 incognito?: boolean;
2525 discarded?: boolean;
26+ autoDiscardable?: boolean;
2627 lastAccessed?: number;
2728}
2829
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+ */
2934export const idleMs = (tab: TidyTab, state: State, now: number): number =>
30− now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
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+};
3142
3243/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
3344export function excluded(url: string, patterns: string[]): boolean {
@@ -55,7 +66,8 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
5566 if (tab.groupId !== -1) {
5667 const g = state.groups[tab.groupId];
5768 if (g?.keep) return false;
58− if ((!g || g.origin === "user") && !settings.tidyUserGroups) 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;
5971 }
6072 if (excluded(tab.url, settings.tidyExclusions)) return false;
6173 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -66,10 +78,18 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
6678 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
6779}
6880
81+/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
6982export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
7083 if (!settings.archiveAfterHours) return [];
7184 const parking = parkingGroupIds(state);
72− return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
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+ );
7393}
7494
7595/** Prepend entries newest first and drop the oldest past the cap. */
@@ -79,6 +99,15 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
7999
80100export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
81101
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+
82111// ---------------------------------------------------------------------------------------------
83112
84113const liveTabs = async (): Promise<TidyTab[]> =>
@@ -95,6 +124,7 @@ const liveTabs = async (): Promise<TidyTab[]> =>
95124 audible: t.audible,
96125 incognito: t.incognito,
97126 discarded: t.discarded,
127+ autoDiscardable: t.autoDiscardable,
98128 lastAccessed: t.lastAccessed,
99129 }));
100130
@@ -104,11 +134,20 @@ export interface SweepReport {
104134 archived: number;
105135}
106136
137+let sweeps: Promise<unknown> = Promise.resolve();
138+
107139/**
108140 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
109− * consent, still respects eligibility).
141+ * consent, still respects eligibility). One at a time: two at once would park, and archive, the
142+ * same tabs twice.
110143 */
111−export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
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> {
112151 const settings = rt.settings();
113152 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
114153 if (settings.tidyMode === "off" && !manual) {
@@ -118,6 +157,7 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
118157 }
119158 const now = rt.now();
120159 let tabs = await liveTabs();
160+ startParkedClocks(rt.state(), tabs, now);
121161 const candidates = parkCandidates(tabs, rt.state(), settings, now);
122162 report.candidates = candidates.length;
123163 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -133,8 +173,52 @@ export async function runSweep(rt: Runtime, manual = false): Promise<SweepReport
133173 return report;
134174}
135175
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+
136219async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
137220 const settings = rt.settings();
221+ const now = rt.now();
138222 const moves: SweepMove[] = [];
139223 const byWindow = new Map<number, TidyTab[]>();
140224 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -142,13 +226,15 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
142226 const ids = tabs.map((t) => t.id);
143227 const s = rt.state();
144228 for (const t of tabs) {
145− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
146− const rec = s.tabs[t.id];
147229 const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
148− if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
230+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
231+ 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+ }
149236 }
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;
237+ let parking = await parkingGroupIn(rt, windowId);
152238 if (parking === undefined) {
153239 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
154240 } else {
@@ -162,12 +248,16 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
162248 rt.log("could not move parking group to the end", e);
163249 }
164250 if (settings.discardParked) {
251+ // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
165252 for (const t of tabs) {
166− if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
253+ if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
167254 }
168255 }
169256 }
170− rt.state().lastSweep = { at: rt.now(), moves };
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] };
171261 rt.commit();
172262 return moves.length;
173263}
@@ -197,18 +287,40 @@ export async function undoSweep(rt: Runtime): Promise<number> {
197287 const last = rt.state().lastSweep;
198288 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
199289 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+ };
200298 let restored = 0;
201299 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
202300 const t = live.get(m.tabId);
203301 if (!t) continue;
204− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
205− else await ungroup(rt, [m.tabId]);
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+ }
206312 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
207313 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
208− if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
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+ }
209320 const rec = rt.state().tabs[m.tabId];
210321 if (rec) {
211322 rec.parkedFrom = undefined;
323+ rec.parkedAt = undefined;
212324 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
213325 }
214326 restored++;
@@ -249,7 +361,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
249361 if (t.id !== undefined) ids.push(t.id);
250362 }
251363 if (ids.length) {
252− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
364+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
253365 }
254366 return ids.length;
255367}
src/shared/health.ts+4−2
@@ -46,7 +46,9 @@ export function notices(s: Health): Notice[] {
4646 case "HOST_NOT_FOUND":
4747 out.push({
4848 tone: "bad", title: "Finish setting up Diagonal",
49− body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen 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",
5052 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
5153 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
5254 });
@@ -80,7 +82,7 @@ export function notices(s: Health): Notice[] {
8082 out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
8183 }
8284 }
83− if (s.timeoutHint && s.health !== "degraded") {
85+ if (s.timeoutHint) {
8486 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." });
8587 }
8688 return out;
src/shared/label.ts+49−8
@@ -7,9 +7,12 @@ export const LABEL_MAX_CHARS = 28;
77export const LABEL_MIN_WORDS = 2;
88export const LABEL_MAX_WORDS = 4;
99
10−const QUOTES = /["'“”‘’«»`]/g;
11−const HAS_QUOTE = /["'“”‘’«»`]/;
12−const TRAILING_PUNCT = /[\s.,;:!?…\-–—]+$/u;
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;
13+const TRAILING_PUNCT = /[\s.,;:!?…\-–—。、,!?:;]+$/u;
14+// A label cut short should not end mid-phrase: "Planning a trip to" reads as "Planning a trip".
15+const DANGLING = new Set(["a", "an", "the", "to", "of", "for", "in", "on", "at", "by", "and", "or", "with", "from"]);
1316
1417/** Repair what is repairable; return undefined when the label cannot be made valid. */
1518export function repairLabel(raw: unknown): string | undefined {
@@ -17,19 +20,50 @@ export function repairLabel(raw: unknown): string | undefined {
1720 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
1821 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
1922 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
20− while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
23+ if (noSpace(words.join(" "))) {
24+ while (words.length > 1 && widthOf(words.join(" ")) > CJK_MAX_CHARS) words.pop();
25+ if (widthOf(words.join(" ")) > CJK_MAX_CHARS) words = [cutToWidth(words.join(" "), CJK_MAX_CHARS)];
26+ } else {
27+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
28+ }
29+ while (words.length > 1 && DANGLING.has(words[words.length - 1].toLowerCase())) words.pop();
2130 let label = words.join(" ").replace(TRAILING_PUNCT, "");
2231 if (!label) return undefined;
23− label = label[0].toLocaleUpperCase() + label.slice(1);
32+ // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
33+ if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
2434 return isValidLabel(label) ? label : undefined;
2535}
2636
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;
43+export const CJK_MIN_CHARS = 2;
44+export const CJK_MAX_CHARS = 16;
45+
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+};
59+
2760export function isValidLabel(label: string): boolean {
2861 const words = label.split(" ").filter(Boolean);
62+ const cjk = noSpace(label);
2963 return (
30− label.length >= LABEL_MIN_CHARS &&
31− label.length <= LABEL_MAX_CHARS &&
32− words.length >= LABEL_MIN_WORDS &&
64+ 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) &&
66+ words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
3367 words.length <= LABEL_MAX_WORDS &&
3468 !HAS_QUOTE.test(label) &&
3569 !TRAILING_PUNCT.test(label) &&
@@ -53,3 +87,10 @@ export function labelOf(title: string | undefined): string {
5387
5488export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
5589 !!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+90−2
@@ -15,6 +15,7 @@ let state: State;
1515let calls: OrganizePayload[];
1616let replies: HostReply<unknown>[];
1717let fallbacks: number[];
18+let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
1819
1920const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
2021 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -32,9 +33,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
3233 settings: () => makeSettings(),
3334 commit: () => undefined,
3435 now: () => 1_000_000,
35− host: async <T,>(_op: string, payload: object) => {
36+ host: async <T,>(_op: string, payload: object, opts?: object) => {
3637 calls.push(JSON.parse(JSON.stringify(payload)));
37− return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
38+ return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
3839 },
3940 naming: { touch: () => undefined } as unknown as Runtime["naming"],
4041 refreshBadge: () => undefined,
@@ -48,6 +49,7 @@ beforeEach(() => {
4849 calls = [];
4950 replies = [];
5051 fallbacks = [];
52+ respond = undefined;
5153 nextGroup = 100;
5254 (globalThis as any).chrome = {
5355 tabs: {
@@ -92,6 +94,7 @@ describe("auto-organize", () => {
9294 await auto.run(1);
9395 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9496 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97+ expect(calls[0].siblingTitles).toEqual(["Rust"]);
9598 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9699 });
97100
@@ -109,6 +112,12 @@ describe("auto-organize", () => {
109112 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
110113 });
111114
115+ it("sorts tabs the browser discarded, which keep their title and address", async () => {
116+ const auto = setup([liveTab(1, { status: "unloaded" }), liveTab(2)]);
117+ await auto.run(1);
118+ expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 1", "Tab 2"]);
119+ });
120+
112121 it("one loose tab with nothing to join waits for company", async () => {
113122 const auto = setup([liveTab(1)]);
114123 await auto.run(1);
@@ -143,4 +152,83 @@ describe("auto-organize", () => {
143152 vi.useRealTimers();
144153 }
145154 });
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+ });
146234});
test/e2e/smoke.mjs+27
@@ -47,6 +47,8 @@ const pages = {
4747 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
4848 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
4949 "/misc": ["Something else", "Unrelated page.", ""],
50+ "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51+ "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
5052};
5153const server = createServer((req, res) => {
5254 const p = pages[req.url.split("?")[0]];
@@ -186,6 +188,31 @@ try {
186188 const pasta = await tabIn(win2, "/pasta");
187189 check("an auto-made group closed down to one tab dissolves, and the tab is not regrouped alone", !!gone && pasta.groupId === -1, JSON.stringify({ gone, pasta: pasta.groupId }));
188190
191+ // 6b. A grouped tab that moves on to an unrelated page leaves its group and joins a better one.
192+ const win3 = await sw.evaluate(async (b) => {
193+ const w = await chrome.windows.create({ url: `${b}/flight` });
194+ for (const p of ["travel", "hotel", "pasta", "cook"]) await chrome.tabs.create({ windowId: w.id, url: `${b}/${p}` });
195+ return w.id;
196+ }, base);
197+ const sorted3 = await until(async () => {
198+ const t = await titlesIn(win3);
199+ return t.split("|").length === 2 ? t : undefined;
200+ }, 25000);
201+ const flight = await tabIn(win3, "/flight");
202+ const lisbonGroup = flight.groupId;
203+ const pastaGroup3 = (await tabIn(win3, "/pasta")).groupId;
204+ await sw.evaluate(async ([id, url]) => chrome.tabs.update(id, { url }), [flight.id, `${base}/risotto`]);
205+ const moved = await until(async () => {
206+ const t = await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id);
207+ return t.groupId === pastaGroup3 ? t : undefined;
208+ }, 25000);
209+ const lisbonLeft = await sw.evaluate(async (g) => (await chrome.tabs.query({ groupId: g })).length, lisbonGroup);
210+ check(
211+ "a grouped tab that moves to an unrelated page leaves for the matching group",
212+ !!sorted3 && !!moved && lisbonLeft === 2,
213+ JSON.stringify({ sorted3, now: (await sw.evaluate(async (id) => chrome.tabs.get(id), flight.id)).groupId, lisbonGroup, pastaGroup3, lisbonLeft }),
214+ );
215+
189216 // 7. Popup renders the healthy state.
190217 await popup.setViewportSize({ width: 360, height: 560 });
191218 await popup.reload();
test/engine.test.ts+185−1
@@ -167,7 +167,10 @@ describe("dirty triggers", () => {
167167
168168 it("title change and path change mark the group dirty", () => {
169169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
170− expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
170+ expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([
171+ { type: "checkFit" },
172+ { type: "dirty", groupId: 10 },
173+ ]);
171174 });
172175
173176 it("reload, fragment and query changes do not", () => {
@@ -237,6 +240,14 @@ describe("auto-organize triggers", () => {
237240 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
238241 });
239242
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+
240251 it("opening a parked tab takes it out of Parked", () => {
241252 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
242253 s.tabs[1].parkedFrom = "Rust async";
@@ -252,3 +263,176 @@ describe("auto-organize triggers", () => {
252263 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
253264 });
254265});
266+
267+describe("fit check", () => {
268+ const group = () => stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
269+ const moveOn = (s = group(), over = {}) =>
270+ step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story", ...over }) }, ctx());
271+
272+ it("a tab in a Diagonal group that goes to a new page is checked once it has loaded", () => {
273+ const loading = moveOn(group(), { status: "loading" });
274+ expect(loading.state.tabs[1].fitPending).toBe(true);
275+ expect(loading.actions).not.toContainEqual({ type: "checkFit" });
276+ const loaded = step(loading.state, { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx());
277+ expect(loaded.actions).toContainEqual({ type: "checkFit" });
278+ });
279+
280+ it("switching to another tab checks the pending one", () => {
281+ const { state } = moveOn(group(), { status: "loading" });
282+ expect(step(state, { type: "tabActivated", tabId: 2 }, ctx()).actions).toContainEqual({ type: "checkFit" });
283+ expect(step(state, { type: "tabActivated", tabId: 1 }, ctx()).actions).not.toContainEqual({ type: "checkFit" });
284+ });
285+
286+ it("leaves groups you named and groups you made alone", () => {
287+ const named = group();
288+ named.groups[10].userNamed = true;
289+ expect(moveOn(named).state.tabs[1].fitPending).toBeUndefined();
290+ const yours = stateWith([tab(1, { groupId: 10 }), tab(2, { groupId: 10 })], [{ id: 10, origin: "user" }]);
291+ expect(moveOn(yours).state.tabs[1].fitPending).toBeUndefined();
292+ expect(moveOn(group(), {}).state.tabs[1].fitPending).toBe(true);
293+ const off = step(group(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://news.example/story" }) }, ctx(0, { autoOrganize: false }));
294+ expect(off.state.tabs[1].fitPending).toBeUndefined();
295+ });
296+
297+ it("a tab you dragged into a group stays there", () => {
298+ const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
299+ const dragged = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
300+ expect(dragged.state.tabs[1].handPlaced).toBe(true);
301+ expect(moveOn(dragged.state).state.tabs[1].fitPending).toBeUndefined();
302+ });
303+
304+ it("a tab Diagonal put in the group is still checked", () => {
305+ const s = stateWith([tab(1), tab(2, { groupId: 10 }), tab(3, { groupId: 10 })], [{ id: 10, origin: "organize" }]);
306+ s.ownAdds[1] = 1_000_000;
307+ const added = step(s, { type: "tabUpdated", tab: tab(1, { groupId: 10 }) }, ctx());
308+ expect(added.state.tabs[1].handPlaced).toBeUndefined();
309+ expect(added.state.ownAdds[1]).toBeUndefined();
310+ expect(moveOn(added.state).state.tabs[1].fitPending).toBe(true);
311+ });
312+
313+ it("leaving the group clears both marks", () => {
314+ const pending = moveOn(group(), { status: "loading" }).state;
315+ pending.tabs[1].handPlaced = true;
316+ const out = step(pending, { type: "tabUpdated", tab: tab(1, { url: "https://news.example/story" }) }, ctx());
317+ expect(out.state.tabs[1].fitPending).toBeUndefined();
318+ expect(out.state.tabs[1].handPlaced).toBeUndefined();
319+ });
320+});
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/fit.test.tsnew file+18
@@ -0,0 +1,18 @@
1+import { describe, expect, it } from "vitest";
2+import { fitVerdict } from "../src/background/fit";
3+
4+const g = (...members: number[]) => ({ title: "", emoji: "", color: "", members });
5+
6+describe("fit verdict (tab 0 against its groupmates)", () => {
7+ it("fits when it shares a topic with any groupmate", () => {
8+ expect(fitVerdict({ groups: [g(0, 2), g(1, 3)], leftovers: [] })).toBe("fits");
9+ });
10+
11+ it("leaves when its groupmates agree and it matches none of them", () => {
12+ expect(fitVerdict({ groups: [g(1, 2, 3)], leftovers: [0] })).toBe("leave");
13+ });
14+
15+ it("is unsure when the groupmates don't agree with each other either", () => {
16+ expect(fitVerdict({ groups: [], leftovers: [0, 1, 2] })).toBe("unsure");
17+ });
18+});
test/naming.test.ts+80−2
@@ -50,6 +50,10 @@ describe("evolving-name policy", () => {
5050 it("same label is never rewritten", () => {
5151 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
5252 });
53+ it("a member that only moved within its page is not a membership change", () => {
54+ const g2 = newGroupRecord(1, 1, "opener", "blue", { title: "Rust async runtimes", lastNamedAt: 1, memberUrls: ["https://a.com/x", "https://b.com/y"] });
55+ expect(shouldApply(g2, "Tokio and async-std", ["https://a.com/x?page=2", "https://b.com/y#top"])).toBe(false);
56+ });
5357 it("user-named groups are skipped", () => {
5458 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
5559 });
@@ -151,15 +155,33 @@ describe("naming queue", () => {
151155 expect(state.groups[7].dirty).toBe(false);
152156 });
153157
154− it("waits while a member is still loading or the group has one tab", async () => {
158+ it("waits while a member is still loading", async () => {
155159 members = [member(1), member(2, { status: "loading" })];
156160 naming.touch(7);
157161 await settle();
162+ expect(calls).toHaveLength(0);
163+ expect(state.groups[7].dirty).toBe(true);
164+ });
165+
166+ it("a group with one tab to name from is not kept waiting; the next tab to join names it", async () => {
158167 members = [member(1)];
159168 naming.touch(7);
160169 await settle();
161170 expect(calls).toHaveLength(0);
162− expect(state.groups[7].dirty).toBe(true);
171+ expect(state.groups[7].dirty).toBe(false);
172+ members = [member(1), member(2)];
173+ markDirty(state.groups[7], 0);
174+ naming.touch(7);
175+ await settle();
176+ expect(calls).toHaveLength(1);
177+ });
178+
179+ it("names a group whose member the browser discarded to save memory", async () => {
180+ members = [member(1), member(2, { status: "unloaded" })];
181+ naming.touch(7);
182+ await settle();
183+ expect(calls).toHaveLength(1);
184+ expect(state.groups[7].dirty).toBe(false);
163185 });
164186
165187 it("new-tab pages do not count as members", async () => {
@@ -198,6 +220,54 @@ describe("naming queue", () => {
198220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
199221 });
200222
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+
201271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
202272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
203273 replies.push(fail);
@@ -229,6 +299,14 @@ describe("naming queue", () => {
229299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
230300 });
231301
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+
232310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
233311 const n = new Naming({
234312 ...(naming as any).d,
test/organize.test.ts+24
@@ -21,6 +21,13 @@ describe("batching", () => {
2121 expect(b.flat()).toHaveLength(30);
2222 });
2323
24+ it("counts Chinese and Japanese characters double, as fm's tokenizer does", () => {
25+ const en = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "x".repeat(300) })), 36, 5000);
26+ const zh = makeBatches(Array.from({ length: 30 }, (_, i) => t(i, { description: "中".repeat(300) })), 36, 5000);
27+ expect(zh.length).toBeGreaterThan(en.length);
28+ expect(zh.flat()).toHaveLength(30);
29+ });
30+
2431 it("maxGroups = min(8, ceil(items / 3))", () => {
2532 expect(maxGroupsFor(18)).toBe(6);
2633 expect(maxGroupsFor(4)).toBe(2);
@@ -100,6 +107,23 @@ describe("existing groups", () => {
100107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
101108 });
102109
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+
103127 it("groups created by batch n come first for batch n+1", () => {
104128 const s = emptyState();
105129 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/sweep.test.tsnew file+181
@@ -0,0 +1,181 @@
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+34−3
@@ -34,6 +34,19 @@ 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+
3750 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
3851 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
3952 });
@@ -51,14 +64,32 @@ describe("park eligibility", () => {
5164});
5265
5366describe("archive", () => {
54− it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
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", () => {
5570 const s = emptyState();
5671 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
57− const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
58− expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
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]);
5975 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
6076 });
6177
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+
6293 it("is newest first and capped, oldest dropped", () => {
6394 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
6495 const existing = [3, 2, 1].map(entry);
test/url.test.ts+20−13
@@ -1,7 +1,8 @@
11import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
33import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
44import { withDefaults } from "../src/background/settings";
5+import labelCases from "../host/tests/golden/labels.json";
56
67describe("url helpers", () => {
78 it("strips www.", () => {
@@ -45,24 +46,21 @@ describe("url helpers", () => {
4546 });
4647});
4748
48−describe("labels", () => {
49− it.each([
50− ['"Rust async runtimes."', "Rust async runtimes"],
51− ["rust async runtimes", "Rust async runtimes"],
52− ["Rust tabs and docs", "Rust and docs"],
53− ["One two three four five six", "One two three four"],
54− ["Machine learning research papers reading list", "Machine learning research"],
55− ])("repairs %s", (raw, fixed) => {
56− expect(repairLabel(raw)).toBe(fixed);
57− });
49+// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
50+const LABEL_CASES = labelCases as [string, string | null][];
5851
59− it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
60− expect(repairLabel(raw)).toBeUndefined();
52+describe("labels", () => {
53+ it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
54+ expect(repairLabel(raw) ?? null).toBe(fixed);
6155 });
6256
6357 it("validates", () => {
6458 expect(isValidLabel("Lisbon trip")).toBe(true);
6559 expect(isValidLabel("Lisbon")).toBe(false);
60+ expect(isValidLabel("日本旅行")).toBe(true);
61+ expect(isValidLabel("京都の旅行計画")).toBe(true);
62+ expect(isValidLabel("日")).toBe(false);
63+ expect(repairLabel("日本旅行。")).toBe("日本旅行");
6664 });
6765
6866 it("emoji outside the list fall back; FE0F is optional", () => {
@@ -70,6 +68,15 @@ describe("labels", () => {
7068 expect(safeEmoji("🦄")).toBe("🧭");
7169 });
7270
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+
7380 it("labelOf strips our emoji prefix", () => {
7481 expect(labelOf("🦀 Rust async")).toBe("Rust async");
7582 expect(labelOf("Plain title")).toBe("Plain title");
tsconfig.json+1
@@ -9,6 +9,7 @@
99 "noEmit": true,
1010 "skipLibCheck": true,
1111 "isolatedModules": true,
12+ "resolveJsonModule": true,
1213 "noFallthroughCasesInSwitch": true,
1314 "forceConsistentCasingInFileNames": true
1415 },