on 6ff63e4 help sign in
.github/workflows/nix.ymldeleted−22
since splitsnapshots
unifiedsplit
@@ -1,22 +0,0 @@
1−name: Nix
2−
3−# Builds the flake's packages on Linux and on Apple silicon, so flake.nix doesn't rot unnoticed.
4−on:
5− push:
6− branches: [master]
7− pull_request:
8−
9−jobs:
10− build:
11− strategy:
12− fail-fast: false
13− matrix:
14− os: [ubuntu-latest, macos-latest]
15− runs-on: ${{ matrix.os }}
16− steps:
17− - uses: actions/checkout@v4
18− - uses: cachix/install-nix-action@v31
19− - name: Build the host and the extension
20− run: nix build .#diagonal-host .#extension -L --no-link --print-out-paths
21− - name: Evaluate the flake
22− run: nix flake check --no-build
.github/workflows/release.yml+7−46
@@ -9,11 +9,8 @@ on:
9permissions:
9permissions:
10 contents: write
10 contents: write
11
11
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.
14concurrency:
12concurrency:
15− group: release
13+ group: release-${{ github.sha }}
16− cancel-in-progress: false
17
14
18jobs:
15jobs:
19 release:
16 release:
@@ -35,23 +32,8 @@ jobs:
35
32
36 - name: Version from commit time
33 - name: Version from commit time
37 id: version
34 id: version
38− env:
39− GH_TOKEN: ${{ github.token }}
40 run: |
35 run: |
41− # The commit's minute, or the next free minute when another commit already released in it. A re-run
36+ version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
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
55 echo "version=$version" >> "$GITHUB_OUTPUT"
37 echo "version=$version" >> "$GITHUB_OUTPUT"
56 echo "Version $version"
38 echo "Version $version"
57
39
@@ -68,38 +50,17 @@ jobs:
68 # Homebrew cask (iko-soy/homebrew-tap, Casks/diagonal.rb) downloads.
50 # Homebrew cask (iko-soy/homebrew-tap, Casks/diagonal.rb) downloads.
69 cp "diagonal-extension-${DIAGONAL_VERSION}.zip" diagonal-extension.zip
51 cp "diagonal-extension-${DIAGONAL_VERSION}.zip" diagonal-extension.zip
70 cat dist/manifest.json | head -5
52 cat dist/manifest.json | head -5
71− # The Chrome Web Store package: no pinned key, no bundled host (docs/store/listing.md).
72− npm run zip:store
73
53
74 - name: Publish release
54 - name: Publish release
75− if: steps.version.outputs.published != 'true'
76 env:
55 env:
77 GH_TOKEN: ${{ github.token }}
56 GH_TOKEN: ${{ github.token }}
78 VERSION: ${{ steps.version.outputs.version }}
57 VERSION: ${{ steps.version.outputs.version }}
79 run: |
58 run: |
80 asset="diagonal-extension-${VERSION}.zip"
59 asset="diagonal-extension-${VERSION}.zip"
81 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"
60 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"
61+ if gh release view "$VERSION" >/dev/null 2>&1; then
62+ # Two commits in the same minute share a version: refresh the asset on the existing release.
63+ gh release upload "$VERSION" "$asset" diagonal-extension.zip --clobber
64+ else
82− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
65+ gh release create "$VERSION" "$asset" diagonal-extension.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
83−
84− # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
85− # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
86− - name: Upload to the Chrome Web Store
87− if: steps.version.outputs.published != 'true'
88− env:
89− CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
90− CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
91− CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
92− CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
93− run: |
94− if [ -z "$CWS_EXTENSION_ID" ] || [ -z "$CWS_REFRESH_TOKEN" ]; then
95− echo "Chrome Web Store secrets not set; skipping upload."
96− exit 0
97 fi
66 fi
98− token=$(curl -fsS https://oauth2.googleapis.com/token \
99− -d client_id="$CWS_CLIENT_ID" -d client_secret="$CWS_CLIENT_SECRET" \
100− -d refresh_token="$CWS_REFRESH_TOKEN" -d grant_type=refresh_token | jq -r .access_token)
101− curl -fsS -X PUT -H "Authorization: Bearer $token" -H "x-goog-api-version: 2" \
102− -T diagonal-store.zip "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$CWS_EXTENSION_ID" | tee upload.json
103− jq -e '.uploadState == "SUCCESS"' upload.json
104− curl -fsS -X POST -H "Authorization: Bearer $token" -H "x-goog-api-version: 2" -H "Content-Length: 0" \
105− "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_EXTENSION_ID/publish" | tee publish.json
.gitignore−2
@@ -6,5 +6,3 @@ diagonal-extension.zip
6host/tests/__pycache__/
6host/tests/__pycache__/
7host/__pycache__/
7host/__pycache__/
8__pycache__/
8__pycache__/
9−dist-store/
10−diagonal-store.zip
PRIVACY.mddeleted−25
@@ -1,25 +0,0 @@
1−# Diagonal privacy policy
2−
3−Diagonal groups and names your browser tabs. This page says what it reads, where that goes, and what it keeps.
4−
5−## What Diagonal reads
6−
7−For each normal (not incognito) tab: its 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 anything typed into forms). In settings you can leave out the page text, or send only the site name instead of the full address.
8−
9−## Where it goes
10−
11−Only to Apple's on-device model on your Mac. Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). The model runs on the Mac, and nothing leaves it.
12−
13−Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
14−
15−## What it keeps
16−
17−In the browser's extension storage on your computer: its settings, which tab groups it manages and their titles, and archived tabs (title, address, group) so you can restore them. The helper writes a log of its own errors to `~/Library/Logs/Diagonal` on your Mac. Uninstalling removes the extension's storage; `brew uninstall --zap diagonal` removes the helper's files and logs.
18−
19−## Sharing
20−
21−Diagonal does not sell, share or transfer your data to anyone, and does not use it for anything other than grouping and naming your tabs.
22−
23−## Contact
24−
25−Open an issue at https://github.com/iko-soy/diagonal/issues.
README.md+30−42
@@ -1,8 +1,6 @@
1−<p align="center"><img src="icons/icon.png" width="160" height="160" alt="Diagonal icon: two glass quarter-circles on a green rounded square"></p>
2−
3# Diagonal
1# Diagonal
4
2
5−Dia-style tab groups for Chromium browsers on macOS (Brave, Chrome, Edge, Vivaldi, Arc, Opera, …), named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
3+Dia-style tab groups for Brave on macOS, named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
6
4
7You open and close tabs as usual. Diagonal does the rest on its own; there are no shortcuts and nothing to press.
5You open and close tabs as usual. Diagonal does the rest on its own; there are no shortcuts and nothing to press.
8
6
@@ -10,16 +8,15 @@ You open and close tabs as usual. Diagonal does the rest on its own; there are n
102. **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.
82. **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.
113. **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.
93. **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.
124. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
104. **Dissolve.** A group Diagonal made that drops to one tab is ungrouped, unless you gave it a title.
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.
11+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.
15
12
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.
13+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.
17
14
18−The 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.
15+The model sees a tab's title, address and `<meta name="description">`, and nothing else.
19
16
20## Requirements
17## Requirements
21
18
22−- A Chromium browser based on Chromium 121 or newer that lets extensions manage tab groups (`chrome.tabGroups`). Chrome, Brave (including Brave Origin), Edge and Chromium do. Browsers with their own tab grouping, such as Arc, Opera and Vivaldi, may not expose it; if so, Diagonal's popup says so and it stays idle.
19+- Brave stable 1.95+ (Chromium 121+ APIs are used)
23- macOS 27 on Apple silicon, with Apple Intelligence on and the on-device model downloaded (`/usr/bin/fm` ships with macOS 27)
20- macOS 27 on Apple silicon, with Apple Intelligence on and the on-device model downloaded (`/usr/bin/fm` ships with macOS 27)
24- Python 3 (standard library only) for the host
21- Python 3 (standard library only) for the host
25
22
@@ -31,31 +28,29 @@ The model sees a tab's title, its address, and up to 500 characters of what the
31brew install iko-soy/tap/diagonal
28brew install iko-soy/tap/diagonal
32```
29```
33
30
34−This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, installs the native host and registers it with every Chromium browser on the Mac.
31+This downloads the latest release, puts the extension at `~/Library/Application Support/Diagonal/extension`, and installs the native host and registers it with Brave. Then, once:
35−
36−Apple's `fm` tool refuses to run until its terms are accepted once per Mac. If they aren't yet, the install shows them (through `sudo fm license`, so it asks for your password) and you agree or decline there. If you decline, the install still finishes and tells you to run `sudo fm license` when you're ready; until then Diagonal's popup says the same. The install ends with "Diagonal's host is ready." once everything works.
37
32
38−Then load the extension once, in each browser you use:
33+1. Accept Apple's terms for the `fm` tool, which it asks for once per Mac before it runs at all: `sudo fm license`. Diagonal writes its model schemas on its own after that.
34+2. Open `brave://extensions` and turn on **Developer mode**.
35+3. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
36+4. Open Diagonal's settings and click **Run self-test**.
39
37
40−1. Open `chrome://extensions` (it works in Brave, Edge and the rest too) and turn on **Developer mode**.
38+To update: `brew upgrade --cask --greedy diagonal`, then click reload on Diagonal at `brave://extensions`. `brew uninstall diagonal` removes the extension folder, the host and its Brave manifest; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
41−2. Click **Load unpacked**, press **Cmd+Shift+G** and paste `~/Library/Application Support/Diagonal/extension`.
42
39
43−Updates come with a plain `brew upgrade`: the tap's cask moves to each new release within half an hour (`.github/workflows/diagonal.yml` in iko-soy/homebrew-tap). Diagonal then notices the newer files on disk and reloads itself within a couple of minutes, or as soon as you open its popup, so there is nothing to click. `brew uninstall diagonal` removes the extension folder, the host and its manifests from every browser; `brew uninstall --zap diagonal` also removes the fm schemas and logs.
44−
45−Chromium browsers only install extensions on their own from a web store, so the one Load unpacked step stays until Diagonal is published there. Everything the Chrome Web Store asks for is ready in [docs/store/listing.md](docs/store/listing.md), with the policy in [PRIVACY.md](PRIVACY.md); a store install still needs the Homebrew helper above.
40+Brave only installs extensions on its own from the Chrome Web Store, so the one Load unpacked step stays until Diagonal is published there.
46
41
47### From a release zip
42### From a release zip
48
43
491. Download `diagonal-extension-<version>.zip` from the [latest release](https://github.com/iko-soy/diagonal/releases/latest) and unzip it somewhere it can stay, for example `~/Applications/Diagonal`.
441. Download `diagonal-extension-<version>.zip` from the [latest release](https://github.com/iko-soy/diagonal/releases/latest) and unzip it somewhere it can stay, for example `~/Applications/Diagonal`.
50−2. Open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked** and pick that folder.
45+2. Open `brave://extensions`, turn on **Developer mode**, click **Load unpacked** and pick that folder.
513. Install the native host once, from Terminal:
463. Install the native host once, from Terminal:
52
47
53 ```sh
48 ```sh
54 bash ~/Applications/Diagonal/install-host.command
49 bash ~/Applications/Diagonal/install-host.command
55 ```
50 ```
56
51
57− (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. It shows Apple's terms for `fm` if they haven't been accepted on this Mac yet, and ends with "Diagonal's host is ready." when everything works.
52+ (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. If it asks you to, run `sudo fm license` once to accept Apple's terms for `fm`.
58−4. Open Diagonal's settings and click **Run check** under "Connection to the model". The browser does not need a restart.
53+4. Open Diagonal's settings and click **Run self-test**. Brave does not need a restart.
59
54
60The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
55The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
61
56
@@ -64,7 +59,7 @@ The zip carries the host (`host/`), `extension-id` and `install-host.command`, w
64#### 1. The extension
59#### 1. The extension
65
60
661. `npm ci && npm run build` writes the unpacked extension to `dist/`.
611. `npm ci && npm run build` writes the unpacked extension to `dist/`.
67−2. Open `chrome://extensions`, turn on **Developer mode**, click **Load unpacked** and pick `dist/`.
62+2. Open `brave://extensions`, turn on **Developer mode**, click **Load unpacked** and pick `dist/`.
683. Check that the ID shown is `mpnodlalikgeehnlnofdkpgapkmbkjdf`. It is pinned by the `key` in `manifest.json`, so it stays the same across reloads and machines.
633. Check that the ID shown is `mpnodlalikgeehnlnofdkpgapkmbkjdf`. It is pinned by the `key` in `manifest.json`, so it stays the same across reloads and machines.
69
64
70#### 2. The native host
65#### 2. The native host
@@ -72,10 +67,10 @@ The zip carries the host (`host/`), `extension-id` and `install-host.command`, w
72Without Nix:
67Without Nix:
73
68
74```sh
69```sh
75−scripts/install-manifest.sh
70+scripts/install-manifest.sh # add --beta / --nightly / --all-channels for other Brave channels
76```
71```
77
72
78−This copies `host/` to `~/.local/share/diagonal-host`, links `~/.local/bin/diagonal-host`, pins its shebang to your `python3`, runs `diagonal-host --register`, which writes `io.diagonal.host.json` into the `NativeMessagingHosts` folder of every Chromium browser's profile folder under `~/Library/Application Support` (known ones such as `Google/Chrome`, `BraveSoftware/Brave-Origin`, `Microsoft Edge`, `Arc/User Data`, plus any other folder with a Chromium `Local State` and browsing history) and checks each one the way the browser reads it, then runs `diagonal-host --install-schemas`, then `diagonal-host --selftest`.
73+This copies `host/` to `~/.local/share/diagonal-host`, links `~/.local/bin/diagonal-host`, pins its shebang to your `python3`, writes `~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json`, runs `diagonal-host --install-schemas`, then `diagonal-host --selftest`.
79
74
80With Nix (nix-darwin + home-manager):
75With Nix (nix-darwin + home-manager):
81
76
@@ -86,14 +81,14 @@ diagonal.url = "path:/path/to/diagonal"; # or a git URL once it lives in a rep
86# home-manager config
81# home-manager config
87imports = [ inputs.diagonal.homeManagerModules.default ];
82imports = [ inputs.diagonal.homeManagerModules.default ];
88programs.diagonal.enable = true;
83programs.diagonal.enable = true;
89−# programs.diagonal.browsers = [ "BraveSoftware/Brave-Origin" "Google/Chrome" ];
84+# programs.diagonal.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
90```
85```
91
86
92`nix build .#extension` builds `dist/` into `./result`; `nix build .#diagonal-host` builds the host.
87`nix build .#extension` builds `dist/` into `./result`; `nix build .#diagonal-host` builds the host.
93
88
94#### 3. Check it
89#### 3. Check it
95
90
96−Open Diagonal's settings page and click **Run check**. It should say "Working" and show a title for a sample group; the full result (`fmAvailable: true`, `schemasOk: true`) is under Troubleshooting. From a terminal, `diagonal-host --selftest` runs the same checks.
91+Open Diagonal's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `diagonal-host --selftest` runs the same checks.
97
92
98## Develop
93## Develop
99
94
@@ -104,8 +99,7 @@ npm run watch # rebuild on change
104npm run typecheck
99npm run typecheck
105npm test # vitest: engine, naming, organize, tidy, url, property tests
100npm test # vitest: engine, naming, organize, tidy, url, property tests
106npm run test:host # unittest: framing, origin, every error code, prompts, validators
101npm run test:host # unittest: framing, origin, every error code, prompts, validators
107−npm run smoke # Linux: loads dist/ in Chromium with the real host and a fake fm, then checks self-update
102+npm run smoke # Linux: loads dist/ in Chromium with the real host and a fake fm
108−npm run zip:store # diagonal-store.zip for the Chrome Web Store (no pinned key, no bundled host)
109npm run check # typecheck + both unit suites
103npm run check # typecheck + both unit suites
110```
104```
111
105
@@ -129,21 +123,15 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
129- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
123- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
130- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
124- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
131- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
125- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
132−- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
126+- **Restarts.** Tab and group IDs change when Brave restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
133−
134−## How fm behaves on macOS 27
135
127
136−Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
128+## Not verified on a real Mac yet
137
129
138−- `fm` is at `/usr/bin/fm`. Until an admin runs `sudo fm license`, every command exits 69 with a terms notice; `fm license --status` reports the state. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
130+Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
139−- `fm schema object` builds the schemas. A nested object needs its own schema: `--object tabs --schema "<json from another fm schema object>" --array` (see `SUB_SCHEMAS` in `host/diagonal-host.py`).
140−- 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.
141−- `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.
142−- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
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`.
146−- 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`.
147−- 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.
148
131
149−Still open: the Nix flake builds both packages on Linux and Apple silicon in CI, but the home-manager module has not been activated on a real Mac.
132+- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. Diagonal reports that as `LICENSE_REQUIRED` with the fix, and the host writes its schemas on the next ping once `fm` works.
133+- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
134+- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
135+- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
136+- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
137+- The Nix flake has not been built. `importNpmLock` avoids an `npmDepsHash`.
docs/store/listing.mddeleted−51
@@ -1,51 +0,0 @@
1−# Chrome Web Store listing
2−
3−Everything the Chrome Web Store developer dashboard asks for, ready to paste. The package is `diagonal-store.zip` from `npm run zip:store` (every release also attaches it).
4−
5−## Store listing
6−
7−- **Name:** Diagonal
8−- **Summary (132 chars max):** Groups your tabs as you browse and names each group with Apple's on-device model. No cloud, nothing to press.
9−- **Category:** Workflow & Planning
10−- **Language:** English
11−- **Description:**
12−
13− > Diagonal keeps your tabs in named groups without you doing anything.
14− >
15− > Open and close tabs as usual. A link you open from a tab joins it in a group. Loose tabs are sorted into matching groups once you stop for a moment. Each group gets a short title with an emoji, written by Apple's on-device model on your Mac. Tabs you haven't touched in a day move to a collapsed Parked group, and you can restore anything Diagonal archived.
16− >
17− > Your choices win: groups you make yourself are left alone, a title you type is kept, and a tab you pull out of a group stays out.
18− >
19− > Requirements: a Mac with Apple silicon running macOS 27 with Apple Intelligence on, plus Diagonal's small helper program, installed with: brew install iko-soy/tap/diagonal
20− >
21− > Private by design: the model runs on your Mac and sees only each tab's title, address and a short summary of the page. Diagonal has no server and no analytics.
22−
23−- **Icon:** `icons/128.png`
24−- **Screenshots (1280×800):** `docs/store/screenshot-popup.png`, `docs/store/screenshot-settings.png`
25−- **Small promo tile (440×280):** `docs/store/promo-small.png`
26−- **Homepage:** https://github.com/iko-soy/diagonal
27−- **Support:** https://github.com/iko-soy/diagonal/issues
28−
29−## Privacy practices
30−
31−- **Single purpose:** Organizes the browser's tabs into named tab groups.
32−- **Permission justifications:**
33− - `tabs`: reads tab titles and addresses to decide which tabs belong together and to name groups.
34− - `tabGroups`: creates, names, collapses and removes the tab groups it manages.
35− - `storage`: keeps settings, which groups it manages, and archived tabs, on this computer.
36− - `alarms`: waits for tabs to settle before grouping, and runs the idle-tab tidy on a schedule.
37− - `nativeMessaging`: talks to Diagonal's helper on the Mac, which runs Apple's on-device model.
38− - `scripting`: reads the short page summary of tabs that were already open when Diagonal was installed.
39− - Host permissions `http://*/*`, `https://*/*`: the content script reads a short summary of each page (description tag, main heading, opening lines), which helps the model group and name tabs.
40−- **Remote code:** No, it does not use remote code.
41−- **Data usage:** collects "Web history" (tab titles and addresses) and "Website content" (a short summary of each page). Check the three certifications: not sold to third parties, not used for unrelated purposes, not used for creditworthiness or lending.
42−- **Privacy policy URL:** https://github.com/iko-soy/diagonal/blob/master/PRIVACY.md
43−
44−## Distribution
45−
46−- **Visibility:** Public (or Unlisted to share by link only)
47−- **Regions:** All
48−
49−## After the first upload
50−
51−The store gives the listing its own extension ID. Put it in `STORE_ORIGIN` in `host/diagonal-host.py` as `chrome-extension://<id>/` so the helper accepts the store install, and set these repository secrets so every release uploads itself: `CWS_EXTENSION_ID`, `CWS_CLIENT_ID`, `CWS_CLIENT_SECRET`, `CWS_REFRESH_TOKEN` (see Google's guide, "Use the Chrome Web Store API").
docs/store/promo-small.pngdeleted

Binary file not shown.

docs/store/screenshot-popup.pngdeleted

Binary file not shown.

docs/store/screenshot-settings.pngdeleted

Binary file not shown.

flake.nix+10−17
@@ -1,5 +1,5 @@
1{
1{
2− description = "Diagonal: Dia-style tab groups for Chromium browsers, named by Apple's on-device model";
2+ description = "Diagonal: Dia-style tab groups for Brave, named by Apple's on-device model";
3
3
4 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
4 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
5
5
@@ -58,38 +58,31 @@
58 description = "Diagonal: names tab groups with Apple's on-device model";
58 description = "Diagonal: names tab groups with Apple's on-device model";
59 path = "${host}/bin/diagonal-host";
59 path = "${host}/bin/diagonal-host";
60 type = "stdio";
60 type = "stdio";
61− allowed_origins = map (id: "chrome-extension://${id}/")
61+ allowed_origins = [ "chrome-extension://${cfg.extensionId}/" ];
62− ([ cfg.extensionId ] ++ lib.optional (cfg.storeExtensionId != "") cfg.storeExtensionId);
63 };
62 };
64 in
63 in
65 {
64 {
66 options.programs.diagonal = {
65 options.programs.diagonal = {
67− enable = lib.mkEnableOption "the Diagonal native messaging host for Chromium browsers";
66+ enable = lib.mkEnableOption "the Diagonal native messaging host for Brave";
68 extensionId = lib.mkOption {
67 extensionId = lib.mkOption {
69 type = lib.types.str;
68 type = lib.types.str;
70 default = extensionId;
69 default = extensionId;
71 description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/diagonal-host.py, so change both together by re-running gen-key.sh.";
70 description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/diagonal-host.py, so change both together by re-running gen-key.sh.";
72 };
71 };
73− storeExtensionId = lib.mkOption {
74− type = lib.types.str;
75− default = "";
76− description = "The Chrome Web Store listing's extension ID, if you installed Diagonal from the store. Must match STORE_ORIGIN in host/diagonal-host.py.";
77− };
78− browsers = lib.mkOption {
72+ channels = lib.mkOption {
79− type = lib.types.listOf lib.types.str;
73+ type = lib.types.listOf (lib.types.enum [ "Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly" ]);
80− default = [ "BraveSoftware/Brave-Browser" "BraveSoftware/Brave-Origin" "Google/Chrome" ];
74+ default = [ "Brave-Browser" ];
81− example = [ "BraveSoftware/Brave-Origin" "Google/Chrome" "Microsoft Edge" "Arc/User Data" ];
82− description = "Browser profile folders under ~/Library/Application Support to install the host manifest into.";
75+ description = "Brave channels to install the host manifest for.";
83 };
76 };
84 };
77 };
85
78
86 config = lib.mkIf cfg.enable {
79 config = lib.mkIf cfg.enable {
87 home.packages = [ host ];
80 home.packages = [ host ];
88 home.file = lib.listToAttrs (map
81 home.file = lib.listToAttrs (map
89− (dir: lib.nameValuePair
82+ (ch: lib.nameValuePair
90− "Library/Application Support/${dir}/NativeMessagingHosts/io.diagonal.host.json"
83+ "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.diagonal.host.json"
91 { text = builtins.toJSON manifest; })
84 { text = builtins.toJSON manifest; })
92− cfg.browsers);
85+ cfg.channels);
93 # `|| true`: a Mac without Apple Intelligence still activates.
86 # `|| true`: a Mac without Apple Intelligence still activates.
94 home.activation.diagonalSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
87 home.activation.diagonalSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
95 ${host}/bin/diagonal-host --install-schemas || true
88 ${host}/bin/diagonal-host --install-schemas || true
host/diagonal-host.py+87−365
@@ -25,39 +25,34 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
25import prompts # noqa: E402
25import prompts # noqa: E402
26import validate # noqa: E402
26import validate # noqa: E402
27
27
28−HOST_VERSION = "0.1.0" # release builds stamp the extension version here (scripts/build.mjs)
28+HOST_VERSION = "0.1.0"
29HOST_NAME = "io.diagonal.host"
29HOST_NAME = "io.diagonal.host"
30ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
30ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
31−# The Chrome Web Store gives the listed extension its own ID. Once the listing exists, put its origin here
32−# ("chrome-extension://<store id>/") so the store install and the unpacked one can both use the host.
33−STORE_ORIGIN = ""
34FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
31FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
35SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
32SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
36SCHEMAS = os.path.join(SUPPORT, "schemas")
33SCHEMAS = os.path.join(SUPPORT, "schemas")
37LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
34LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
38LOG_MAX_BYTES = 5 * 1024 * 1024
35LOG_MAX_BYTES = 5 * 1024 * 1024
39−# fm's context holds prompt and reply together. Measured on macOS 27 with the topics schema: a 7,400-token
36+CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
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
46MAX_REQUEST = 64 * 1024 * 1024
37MAX_REQUEST = 64 * 1024 * 1024
47MAX_REPLY = 1024 * 1024
38MAX_REPLY = 1024 * 1024
48OPS = {"ping", "name", "organize"}
39OPS = {"ping", "name", "organize"}
49
40
50−# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the nested
41+# `fm schema` argument lists (section 8). The nested form is unverified on a real macOS 27 machine;
51−# object's schema as JSON from `--schema`; "{TabTopic}" is replaced by the output of SUB_SCHEMAS["TabTopic"].
42+# when it fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
52−SUB_SCHEMAS = {
53− "TabTopic": ["schema", "object", "--name", "TabTopic", "--integer", "index", "--string", "topic"],
54−}
55SCHEMA_COMMANDS = {
43SCHEMA_COMMANDS = {
56 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
44 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
57− "topics.json": ["schema", "object", "--name", "Topics", "--object", "tabs", "--schema", "{TabTopic}", "--array"],
45+ "organize.json": ["schema", "object", "--name", "Organized",
46+ "--object", "groups", "--array",
47+ "--string", "title", "--string", "emoji", "--string", "color",
48+ "--integer", "existing", "--integer", "members", "--array",
49+ "--integer", "leftovers", "--array"],
50+}
51+FALLBACK_SCHEMA_COMMANDS = {
52+ "organize-labels.json": ["schema", "object", "--name", "TopicLabels",
53+ "--string", "labels", "--array", "--string", "emojis", "--array", "--string", "colors", "--array"],
54+ "organize-assign.json": ["schema", "object", "--name", "Assignment", "--integer", "assignment", "--array"],
58}
55}
59−# Organize schemas written by older hosts; removed when the current ones are installed.
60−LEGACY_SCHEMAS = ["organize.json", "organize-labels.json", "organize-assign.json"]
61
56
62
57
63class Fail(Exception):
58class Fail(Exception):
@@ -135,7 +130,7 @@ def validate_request(req):
135 if not isinstance(it, dict):
130 if not isinstance(it, dict):
136 raise Fail("BAD_REQUEST", f"item {n} is not an object")
131 raise Fail("BAD_REQUEST", f"item {n} is not an object")
137 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
132 clean.append({"i": n, "title": _trim(it.get("title"), 120), "url": _trim(it.get("url"), 300),
138− "description": _trim(it.get("description"), 500)})
133+ "description": _trim(it.get("description"), 300)})
139 payload["items"] = clean
134 payload["items"] = clean
140 for key in ("siblingTitles", "mustDifferFrom"):
135 for key in ("siblingTitles", "mustDifferFrom"):
141 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
136 payload[key] = [_trim(t, 60) for t in payload.get(key) or [] if isinstance(t, str)][:20]
@@ -148,8 +143,9 @@ def validate_request(req):
148 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
143 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
149 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
144 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
150 ]
145 ]
151− # fm on macOS 27 only has the on-device model; older extensions may still ask for "pcc".
146+ model = opts.get("model", "system")
152− opts["model"] = "system"
147+ if model not in ("system", "pcc"):
148+ raise Fail("BAD_REQUEST", "opts.model must be system or pcc")
153 return req
149 return req
154
150
155
151
@@ -182,7 +178,7 @@ def classify(stderr, returncode=None):
182
178
183
179
184def fm_env():
180def fm_env():
185− return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8", "NO_COLOR": "1"}
181+ return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8"}
186
182
187
183
188def schema_path(name):
184def schema_path(name):
@@ -190,46 +186,10 @@ def schema_path(name):
190 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
186 return path if os.path.isfile(path) and os.path.getsize(path) > 0 else None
191
187
192
188
193−# How much of each page's text to send, most first: when a batch doesn't fit the budget, the pages' text
189+def check_budget(prompt, n_items):
194−# is shortened, then dropped, before the host gives up and asks for a smaller batch.
190+ if len(prompt) > CHAR_BUDGET:
195−DESC_CAPS = (500, 200, 0)
196−
197−
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−
223−def 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."""
226− for cap in DESC_CAPS:
227− prompt = build(cap)
228− tokens = prompt_tokens(prompt)
229− if tokens <= TOKEN_BUDGET:
230− return prompt
231− allowed = max(1, int(n_items * TOKEN_BUDGET / tokens))
191+ allowed = max(1, int(n_items * CHAR_BUDGET / len(prompt)))
232− raise Fail("OVER_BUDGET", f"prompt is about {tokens} tokens, budget {TOKEN_BUDGET}", retryable=True, allowedItems=allowed)
192+ raise Fail("OVER_BUDGET", f"prompt is {len(prompt)} chars, budget {CHAR_BUDGET}", retryable=True, allowedItems=allowed)
233
193
234
194
235def extract_json(stdout):
195def extract_json(stdout):
@@ -249,189 +209,92 @@ def extract_json(stdout):
249 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
209 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
250
210
251
211
252−# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
212+def run_fm(prompt, schema, model, timeout_s):
253−# refused call is retried once in the mode Apple provides for transforming given content.
213+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "--", prompt]
254−PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255−USAGE_EXIT = 64
256−
257−
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."""
266 try:
214 try:
267− return _run_fm(prompt, schema, opts, [])
268− except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
270− raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
276−
277−
278−def _run_fm(prompt, schema, opts, extra):
279− # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
280− # in the process list. fm has no timeout of its own; subprocess enforces ours.
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))
287− try:
288− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
215+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
289 except FileNotFoundError:
216 except FileNotFoundError:
290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
217 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291 except subprocess.TimeoutExpired:
218 except subprocess.TimeoutExpired:
292− raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
219+ raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
293 if p.returncode != 0:
220 if p.returncode != 0:
294 code = classify(p.stderr, p.returncode)
221 code = classify(p.stderr, p.returncode)
295 if code == "LICENSE_REQUIRED":
222 if code == "LICENSE_REQUIRED":
296 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
223 raise Fail(code, LICENSE_MESSAGE, raw=(p.stderr or "")[:2000])
297− # 64 is fm's usage error (unknown flag or value): the same call fails the same way again.
298− raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=p.returncode != USAGE_EXIT,
224+ raise Fail(code, (p.stderr or f"fm exited {p.returncode}").strip()[:500], retryable=True, raw=(p.stderr or "")[:2000])
299− raw=(p.stderr or "")[:2000])
300 return extract_json(p.stdout)
225 return extract_json(p.stdout)
301
226
302
227
303# ----- ops --------------------------------------------------------------------------------------
228# ----- ops --------------------------------------------------------------------------------------
304
229
305def organize_mode():
230def organize_mode():
306− return "topics" if schema_path("topics.json") else "missing"
231+ if schema_path("organize.json"):
232+ return "nested"
233+ if schema_path("organize-labels.json") and schema_path("organize-assign.json"):
234+ return "two-call"
235+ return "missing"
307
236
308
237
309def op_ping(_payload, _opts):
238def op_ping(_payload, _opts):
310 if not os.path.exists(FM):
239 if not os.path.exists(FM):
311− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
240+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
241 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313 license_required = False
242 license_required = False
314 try:
243 try:
315− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
244+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
245 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
246 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318 license_required, msg = True, LICENSE_MESSAGE
247 license_required, msg = True, LICENSE_MESSAGE
319 except subprocess.TimeoutExpired:
248 except subprocess.TimeoutExpired:
320 ok, msg = False, "fm available timed out"
249 ok, msg = False, "fm available timed out"
321− schema_message = None
250+ if ok and not schemas_ok():
322− if ok:
251+ # The installer could not write them (typically: terms not yet accepted). Now fm works, so do it here;
323− try:
252+ # stdout is the native-messaging channel, so the installer's progress lines are swallowed.
324− ensure_schemas()
253+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
325− except Fail as f:
254+ install_schemas()
326− schema_message = f.message
327 mode = organize_mode()
255 mode = organize_mode()
328− reply = {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
256+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": ok, "fmMessage": msg,
329− "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
257+ "licenseRequired": license_required, "schemasOk": schemas_ok(), "organizeMode": mode}
330− if schema_message:
331− reply["schemaMessage"] = schema_message
332− return reply
333−
334−
335−# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 3: organize
336−# asks for one topic per tab (topics.json) instead of whole groups.
337−SCHEMA_VERSION = "3"
338−
339−
340−def schemas_current():
341− try:
342− with open(os.path.join(SCHEMAS, "version"), encoding="utf-8") as f:
343− return f.read().strip() == SCHEMA_VERSION
344− except OSError:
345− return False
346
258
347
259
348def schemas_ok():
260def schemas_ok():
349 return bool(schema_path("name.json")) and organize_mode() != "missing"
261 return bool(schema_path("name.json")) and organize_mode() != "missing"
350
262
351
263
352−def ensure_schemas():
353− """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
354− accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
355− if schemas_ok() and schemas_current():
356− return
357− out = io.StringIO()
358− with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
359− rc = install_schemas()
360− if rc == INSTALL_LICENSE_EXIT:
361− raise Fail("LICENSE_REQUIRED", LICENSE_MESSAGE)
362− if not schemas_ok():
363− lines = [ln for ln in out.getvalue().splitlines() if ln.strip()]
364− why = lines[-1] if lines else f"fm schema exited {rc}"
365− raise Fail("SCHEMA_MISSING", f"fm could not write Diagonal's schema files: {why}"[:500], raw=out.getvalue()[-2000:])
366−
367−
368−def _fit_overflow(f, n_items):
369− """fm's own context-size error carries no item count; estimate one so the extension can split the batch."""
370− if f.code == "OVER_BUDGET" and "allowedItems" not in f.extra:
371− f.extra["allowedItems"] = max(1, (n_items * 2) // 3)
372− f.retryable = True
373− return f
374−
375−
376def op_name(payload, opts):
264def op_name(payload, opts):
377− ensure_schemas()
378 schema = schema_path("name.json")
265 schema = schema_path("name.json")
379 if not schema:
266 if not schema:
380− raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
267+ raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "name.json"))
381− prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
268+ prompt = prompts.build_name_prompt(payload, strict=opts.get("strict", False))
269+ check_budget(prompt, len(payload["items"]))
270+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
382 try:
271 try:
383− out = run_fm(prompt, schema, opts)
384− except Fail as f:
385− raise _fit_overflow(f, len(payload["items"]))
386− try:
387− return validate.validate_name(out, payload), prompts.size(prompt), "name"
272+ return validate.validate_name(out, payload), len(prompt), "name"
388 except validate.ValidationError as e:
273 except validate.ValidationError as e:
389 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
274 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
390
275
391
276
392def op_organize(payload, opts):
277def op_organize(payload, opts):
393− ensure_schemas()
278+ strict = opts.get("strict", False)
394− schema = schema_path("topics.json")
279+ mode = organize_mode()
395− if not schema:
396− raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
397− prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
398− try:
399− out = run_fm(prompt, schema, opts)
400− except Fail as f:
401− raise _fit_overflow(f, len(payload["items"]))
402− _, owner = prompts.topic_items(payload)
403 try:
280 try:
404− result = validate.organize_from_topics(out, payload, owner)
281+ if mode == "nested":
282+ prompt = prompts.build_organize_prompt(payload, strict=strict)
283+ check_budget(prompt, len(payload["items"]))
284+ out = run_fm(prompt, schema_path("organize.json"), opts["model"], opts["timeout_s"])
285+ return validate.validate_organize(out, payload), len(prompt), "nested"
286+ if mode == "two-call":
287+ p1 = prompts.build_labels_prompt(payload, strict=strict)
288+ check_budget(p1, len(payload["items"]))
289+ labels = run_fm(p1, schema_path("organize-labels.json"), opts["model"], opts["timeout_s"])
290+ names = [x for x in (labels.get("labels") or []) if isinstance(x, str)] if isinstance(labels, dict) else []
291+ p2 = prompts.build_assign_prompt(payload, names, strict=strict)
292+ check_budget(p2, len(payload["items"]))
293+ assign = run_fm(p2, schema_path("organize-assign.json"), opts["model"], opts["timeout_s"])
294+ return validate.organize_from_two_calls(labels, assign, payload), len(p1) + len(p2), "two-call"
405 except validate.ValidationError as e:
295 except validate.ValidationError as e:
406− raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
296+ raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True)
407− chars = prompts.size(prompt)
297+ raise Fail("SCHEMA_MISSING", os.path.join(SCHEMAS, "organize.json"))
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)
410− return result, chars, "topics"
411−
412−
413−def name_new_groups(result, payload, opts):
414− """Title each new group with the name op (well under a second each on fm), so groups appear named.
415− A group that fails, runs past the request's deadline, or gets a title another group in the window
416− already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
417− taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
418− chars = 0
419− for g in result["groups"]:
420− if "existing" in g or g.get("title"):
421− continue
422− if time_left(opts) < MIN_CALL_S:
423− break
424− sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425− try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
427− except Fail:
428− continue
429− chars += n
430− if any(validate.same_label(named["title"], t) for t in taken):
431− continue
432− g["title"], g["emoji"] = named["title"], named["emoji"]
433− taken.append(named["title"])
434− return chars
435
298
436
299
437def handle(req):
300def handle(req):
@@ -444,8 +307,8 @@ def handle(req):
444 raw_opts = req.get("opts") or {}
307 raw_opts = req.get("opts") or {}
445 timeout_ms = raw_opts.get("timeoutMs", 45000)
308 timeout_ms = raw_opts.get("timeoutMs", 45000)
446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
309 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
310+ opts = {"model": raw_opts.get("model", "system"), "strict": bool(raw_opts.get("strict")),
447− timeout_s = max(5.0, min(120.0, timeout_ms / 1000))
311+ "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}
449 op = req["op"]
312 op = req["op"]
450 if op == "ping":
313 if op == "ping":
451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
314 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -494,24 +357,8 @@ def install_schemas():
494
357
495 def write(name, args):
358 def write(name, args):
496 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
359 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
497− subs = {}
498− for key, sub_args in SUB_SCHEMAS.items():
499− if "{%s}" % key not in args:
500− continue
501− try:
502− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
503− except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504− print(f" {name}: fm failed ({e})", file=sys.stderr)
505− return "error"
506− if license_needed(p.returncode, p.stdout + p.stderr):
507− return "license"
508− if p.returncode != 0 or not p.stdout.strip():
509− print(f" {name}: fm schema ({key}) exited {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
510− return "rejected"
511− subs["{%s}" % key] = p.stdout.strip()
512− args = [subs.get(a, a) for a in args]
513 try:
360 try:
514− p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
361+ p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
362 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516 print(f" {name}: fm failed ({e})", file=sys.stderr)
363 print(f" {name}: fm failed ({e})", file=sys.stderr)
517 return "error"
364 return "error"
@@ -531,19 +378,24 @@ def install_schemas():
531 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
378 print(" Diagonal writes its schemas on its own once that is done.", file=sys.stderr)
532 return INSTALL_LICENSE_EXIT
379 return INSTALL_LICENSE_EXIT
533 ok &= first is True
380 ok &= first is True
534− topics = write("topics.json", SCHEMA_COMMANDS["topics.json"])
381+ nested = write("organize.json", SCHEMA_COMMANDS["organize.json"])
535− if topics == "license":
536− return INSTALL_LICENSE_EXIT
537− ok &= topics is True
382+ if nested is True:
538− if ok:
383+ for name in FALLBACK_SCHEMA_COMMANDS:
539− for name in LEGACY_SCHEMAS:
540 try:
384 try:
541 os.remove(os.path.join(SCHEMAS, name))
385 os.remove(os.path.join(SCHEMAS, name))
542 except FileNotFoundError:
386 except FileNotFoundError:
543 pass
387 pass
544− if ok:
388+ elif nested != "rejected":
389+ # fm did not get as far as judging the schema: no reason to think nesting is unsupported.
390+ return INSTALL_LICENSE_EXIT if nested == "license" else 1
391+ else:
392+ print(" nested organize schema unsupported; using the two-call fallback", file=sys.stderr)
393+ try:
545− with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
394+ os.remove(os.path.join(SCHEMAS, "organize.json"))
546− f.write(SCHEMA_VERSION + "\n")
395+ except FileNotFoundError:
396+ pass
397+ for name, args in FALLBACK_SCHEMA_COMMANDS.items():
398+ ok &= write(name, args) is True
547 return 0 if ok else 1
399 return 0 if ok else 1
548
400
549
401
@@ -568,16 +420,14 @@ def selftest():
568
420
569 ping = op_ping({}, {})
421 ping = op_ping({}, {})
570 check("fm found", os.path.exists(FM), FM)
422 check("fm found", os.path.exists(FM), FM)
571− if not os.path.exists(FM):
572− return 1 # nothing else can work without it
573− if ping.get("licenseRequired"):
423+ if ping["licenseRequired"]:
574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
424 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575 else:
425 else:
576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
426 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577− for name in SCHEMA_COMMANDS:
427+ for name in ["name.json", *(list(FALLBACK_SCHEMA_COMMANDS) if organize_mode() == "two-call" else ["organize.json"])]:
578 path = os.path.join(SCHEMAS, name)
428 path = os.path.join(SCHEMAS, name)
579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
429 readable = os.path.isfile(path) and os.path.getsize(path) > 0
580− hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
430+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
581 check(f"schema {name}", readable, path if readable else hint)
431 check(f"schema {name}", readable, path if readable else hint)
582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
432 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583 if ping["fmAvailable"] and schema_path("name.json"):
433 if ping["fmAvailable"] and schema_path("name.json"):
@@ -589,137 +439,13 @@ def selftest():
589 return 0 if failures == 0 else 1
439 return 0 if failures == 0 else 1
590
440
591
441
592−# ----- registering with browsers ------------------------------------------------------------------
593−
594−# Chromium reads per-user host manifests from <user data dir>/NativeMessagingHosts. Known user data
595−# dirs under ~/Library/Application Support; anything else that looks like a Chromium browser profile
596−# is found by browser_dirs()' scan.
597−KNOWN_BROWSER_DIRS = [
598− "Google/Chrome", "Google/Chrome Beta", "Google/Chrome Dev", "Google/Chrome Canary", "Chromium",
599− "BraveSoftware/Brave-Browser", "BraveSoftware/Brave-Origin", "BraveSoftware/Brave-Browser-Beta",
600− "BraveSoftware/Brave-Browser-Nightly", "Microsoft Edge", "Microsoft Edge Beta", "Microsoft Edge Dev",
601− "Microsoft Edge Canary", "Vivaldi", "Vivaldi Snapshot", "Arc/User Data", "Dia/User Data",
602− "com.operasoftware.Opera", "com.operasoftware.OperaGX", "Thorium", "net.imput.helium", "Yandex/YandexBrowser",
603−]
604−BROWSER_SCAN_DEPTH = 3
605−REGISTERED_LIST = "registered-browsers.txt" # next to the host: where --register wrote manifests
606−
607−
608−def app_support():
609− return os.environ.get("DIAGONAL_APP_SUPPORT", os.path.expanduser("~/Library/Application Support"))
610−
611−
612−def looks_like_browser(user_data_dir):
613− """A Chromium browser's user data dir: `Local State` plus a profile with browsing data.
614− Electron apps also have `Local State`, but no History, Favicons or Top Sites."""
615− if not os.path.isfile(os.path.join(user_data_dir, "Local State")):
616− return False
617− try:
618− profiles = [e for e in os.listdir(user_data_dir) if e == "Default" or e.startswith("Profile ")]
619− except OSError:
620− return False
621− return any(os.path.exists(os.path.join(user_data_dir, p, f)) for p in profiles for f in ("History", "Favicons", "Top Sites"))
622−
623−
624−def browser_dirs():
625− base = app_support()
626− found = [os.path.join(base, rel) for rel in KNOWN_BROWSER_DIRS if os.path.isdir(os.path.join(base, rel))]
627− for root, dirs, _files in os.walk(base):
628− depth = os.path.relpath(root, base).count(os.sep) + (root != base)
629− if root != base and (root in found or looks_like_browser(root)):
630− if root not in found:
631− found.append(root)
632− dirs[:] = [] # profiles inside need no scan
633− continue
634− if depth >= BROWSER_SCAN_DEPTH or os.path.basename(root) == "Diagonal":
635− dirs[:] = []
636− return sorted(set(found))
637−
638−
639−def manifest_problems(path, host_path):
640− try:
641− m = json.load(open(path, encoding="utf-8"))
642− except (OSError, ValueError) as e:
643− return [f"unreadable ({e})"]
644− problems = []
645− if m.get("name") != HOST_NAME:
646− problems.append(f"name is {m.get('name')!r}")
647− if m.get("path") != host_path or not os.path.isabs(host_path):
648− problems.append(f"path is {m.get('path')!r}")
649− elif not os.access(host_path, os.X_OK):
650− problems.append(f"host {host_path} is missing or not executable")
651− if m.get("allowed_origins") != allowed_origins():
652− problems.append(f"allowed_origins is {m.get('allowed_origins')}")
653− return problems
654−
655−
656−def register(host_path):
657− """Write this host's manifest into every Chromium browser's NativeMessagingHosts folder, check each
658− one the way the browser will read it, and remember where they went for --unregister."""
659− host_path = os.path.abspath(host_path)
660− body = json.dumps(manifest_for(host_path), indent=2) + "\n"
661− dirs = browser_dirs()
662− if not dirs:
663− print("No Chromium browser found. Open your browser once, then run this again.", file=sys.stderr)
664− return 1
665− written, failures = [], 0
666− for d in dirs:
667− target_dir = os.path.join(d, "NativeMessagingHosts")
668− target = os.path.join(target_dir, f"{HOST_NAME}.json")
669− try:
670− os.makedirs(target_dir, exist_ok=True)
671− with open(target + ".tmp", "w", encoding="utf-8") as f:
672− f.write(body)
673− os.replace(target + ".tmp", target) # a browser never reads a half-written file
674− except OSError as e:
675− print(f" {os.path.relpath(d, app_support())}: could not write ({e})", file=sys.stderr)
676− failures += 1
677− continue
678− problems = manifest_problems(target, host_path)
679− if problems:
680− print(f" {os.path.relpath(d, app_support())}: {'; '.join(problems)}", file=sys.stderr)
681− failures += 1
682− else:
683− print(f"registered: {os.path.relpath(d, app_support())}")
684− written.append(target)
685− try:
686− with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), REGISTERED_LIST), "w", encoding="utf-8") as f:
687− f.write("\n".join(written) + "\n")
688− except OSError:
689− pass
690− # One browser that could not be written (a locked folder, a stale profile) does not undo the others:
691− # the install goes on and the ones that failed were named above.
692− return 0 if len(dirs) - failures > 0 else 1
693−
694−
695−def unregister():
696− """Remove every manifest --register wrote, plus any other copy of ours a browser folder still has."""
697− listed = []
698− try:
699− listed = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), REGISTERED_LIST), encoding="utf-8").read().split("\n")
700− except OSError:
701− pass
702− candidates = set(filter(None, listed)) | {os.path.join(d, "NativeMessagingHosts", f"{HOST_NAME}.json") for d in browser_dirs()}
703− for path in sorted(candidates):
704− try:
705− os.remove(path)
706− print(f"removed: {path}")
707− except OSError:
708− pass
709− return 0
710−
711−
712def manifest_for(path):
442def manifest_for(path):
713 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
443 return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
714− "path": os.path.abspath(path), "type": "stdio", "allowed_origins": allowed_origins()}
444+ "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
715−
716−
717−def allowed_origins():
718− return [o for o in (ALLOWED_ORIGIN, STORE_ORIGIN) if o]
719
445
720
446
721def origin_ok(arg):
447def origin_ok(arg):
722− return isinstance(arg, str) and arg.rstrip("/") + "/" in allowed_origins()
448+ return isinstance(arg, str) and arg.rstrip("/") + "/" == ALLOWED_ORIGIN
723
449
724
450
725def main(argv=None):
451def main(argv=None):
@@ -728,10 +454,6 @@ def main(argv=None):
728 return selftest()
454 return selftest()
729 if argv and argv[0] == "--install-schemas":
455 if argv and argv[0] == "--install-schemas":
730 return install_schemas()
456 return install_schemas()
731− if argv and argv[0] == "--register":
732− return register(argv[1] if len(argv) > 1 else sys.argv[0])
733− if argv and argv[0] == "--unregister":
734− return unregister()
735 if argv and argv[0] == "--print-manifest":
457 if argv and argv[0] == "--print-manifest":
736 print(json.dumps(manifest_for(argv[1] if len(argv) > 1 else os.path.realpath(__file__)), indent=2))
458 print(json.dumps(manifest_for(argv[1] if len(argv) > 1 else os.path.realpath(__file__)), indent=2))
737 return 0
459 return 0
host/prompts.py+80−64
@@ -1,9 +1,4 @@
1−"""Section 9 prompt templates. Pure functions: payload in, Prompt out.
1+"""Section 9 prompt templates. Pure functions: payload in, prompt text out."""
2−
3−A Prompt has fixed `instructions` (passed to `fm respond -i`, so it may show in the process list and must hold
4−no tab or group text) and a `text` with everything from the browser (sent on stdin). Keeping the rules in
5−the instructions also makes it harder for a tab title to override them."""
6−from collections import namedtuple
7from urllib.parse import urlsplit
2from urllib.parse import urlsplit
8
3
9from validate import COLORS, all_emoji
4from validate import COLORS, all_emoji
@@ -11,12 +6,6 @@ from validate import COLORS, all_emoji
11EMOJI_LIST = " ".join(all_emoji())
6EMOJI_LIST = " ".join(all_emoji())
12STRICT_LINE = "Reply with only the object, nothing before or after it."
7STRICT_LINE = "Reply with only the object, nothing before or after it."
13
8
14−Prompt = namedtuple("Prompt", "instructions text")
15−
16−
17−def size(p):
18− return len(p.instructions) + len(p.text)
19−
20
9
21def _clean(s, limit):
10def _clean(s, limit):
22 s = " ".join(str(s or "").split())
11 s = " ".join(str(s or "").split())
@@ -37,10 +26,10 @@ def render_address(url):
37 return _clean(host + path + query, 160)
26 return _clean(host + path + query, 160)
38
27
39
28
40−def render_items(items, desc_cap=500):
29+def render_items(items):
41 lines = []
30 lines = []
42 for it in items:
31 for it in items:
43− desc = _clean(it.get("description"), desc_cap) or "-"
32+ desc = _clean(it.get("description"), 300) or "-"
44 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
33 lines.append(f"{it['i']} | {_clean(it.get('title'), 120)} | {render_address(it.get('url', ''))} | {desc}")
45 return "\n".join(lines)
34 return "\n".join(lines)
46
35
@@ -49,66 +38,93 @@ def _quoted(titles):
49 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
38 return ", ".join(f'"{_clean(t, 60)}"' for t in titles)
50
39
51
40
52−def build_name_prompt(payload, strict=False, desc_cap=500):
41+def build_name_prompt(payload, strict=False):
53 rules = [
42 rules = [
54 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
43 "- Title: 2 to 4 words, sentence case, no punctuation at the end, no quotes.",
55 "- Describe the topic or task, not the website, unless every tab is the same site.",
44 "- Describe the topic or task, not the website, unless every tab is the same site.",
56 "- Never use the words tab, tabs, group, misc, various, stuff.",
45 "- Never use the words tab, tabs, group, misc, various, stuff.",
57 "- Write the title in the language most tab titles use.",
46 "- Write the title in the language most tab titles use.",
58 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
47 f"- Emoji: exactly one, chosen from this list: {EMOJI_LIST}",
59− "- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.",
60− "- Never reuse a name listed under other groups or not allowed.",
61 ]
48 ]
62− if strict:
63− rules.append(f"- {STRICT_LINE}")
64− instructions = (
65− "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
66− "Rules:\n" + "\n".join(rules)
67− )
68− context = []
69 current = payload.get("currentTitle")
49 current = payload.get("currentTitle")
70 if current:
50 if current:
71− context.append(f'Current title: "{_clean(current, 60)}"')
51+ rules.append(f'- Current title: "{_clean(current, 60)}". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.')
72 siblings = payload.get("siblingTitles") or []
52 siblings = payload.get("siblingTitles") or []
73 if siblings:
53 if siblings:
74− context.append(f"Other groups in this window: {_quoted(siblings)}")
54+ rules.append(f"- Other groups in this window are named: {_quoted(siblings)}. Do not reuse those names.")
75 differ = payload.get("mustDifferFrom") or []
55 differ = payload.get("mustDifferFrom") or []
76 if differ:
56 if differ:
77− context.append(f"Not allowed: {_quoted(differ)}")
57+ rules.append(f"- The title must not be any of: {_quoted(differ)}.")
58+ if strict:
59+ rules.append(f"- {STRICT_LINE}")
60+ return (
61+ "You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.\n\n"
62+ "Rules:\n" + "\n".join(rules) + "\n\n"
78− text = ("\n".join(context) + "\n\n" if context else "") + "Tabs (index | title | address | what the page says):\n" + render_items(payload["items"], desc_cap) + "\n"
63+ "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
79− return Prompt(instructions, text)
64+ )
80−
65+
81−
66+
82−# Organize asks for one broad topic per tab and the host groups tabs with the same topic. On fm (macOS 27)
67+def _organize_rules(max_groups, strict):
83−# this was reliable where asking the model for whole groups was not: it set "existing" on nearly every
68+ rules = [
84−# new group, and specific topics gave every tab its own.
69+ "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
85−TOPICS_INSTRUCTIONS = (
70+ f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
86− "You label browser tabs with broad topics so that related tabs can be grouped.\n\n"
71+ '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
87− "Rules:\n"
72+ f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
88− "- Give every listed index exactly one topic of one or two words.\n"
73+ "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
89− "- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.\n"
74+ "- Write titles in the language most tab titles use.",
90− "- Use exactly the same topic words for tabs that belong together.\n"
75+ ]
91− "- Group by what the page is about, not by website.\n"
76+ if strict:
92− "- Write topics in English."
77+ rules.append(f"- {STRICT_LINE}")
93−)
78+ return rules
94−
79+
95−
80+
96−def topic_items(payload, desc_cap=500):
81+def _existing_block(existing):
97− """The tabs to sort, then up to two example tabs of each existing group, numbered after them.
82+ if not existing:
98− Returns (lines, owner) where owner maps an example's index to its group number."""
83+ return ""
99− items = payload["items"]
84+ lines = [f"{g['g']} | {_clean(g.get('title'), 60)} | {'; '.join(_clean(s, 60) for s in (g.get('samples') or [])[:2]) or '-'}" for g in existing]
100− lines = [render_items(items, desc_cap)] if items else []
85+ return "Existing groups (number | title | examples):\n" + "\n".join(lines) + "\n\n"
101− owner = {}
86+
102− n = len(items)
87+
103− for g in payload.get("existingGroups") or []:
88+def build_organize_prompt(payload, strict=False):
104− for sample in (g.get("samples") or [])[:2]:
89+ return (
105− owner[n] = g["g"]
90+ "You sort browser tabs into topic groups.\n\n"
106− lines.append(f"{n} | {_clean(sample, 120)} | - | -")
91+ "Rules:\n" + "\n".join(_organize_rules(payload.get("maxGroups", 8), strict)) + "\n\n"
107− n += 1
92+ + _existing_block(payload.get("existingGroups") or [])
108− return "\n".join(lines), owner
93+ + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
109−
94+ )
110−
95+
111−def build_topics_prompt(payload, strict=False, desc_cap=500):
96+
112− instructions = TOPICS_INSTRUCTIONS + (f"\n- {STRICT_LINE}" if strict else "")
97+# Two-call fallback (section 8) for an `fm schema` that cannot nest arrays of objects.
113− body, _ = topic_items(payload, desc_cap)
98+
114− return Prompt(instructions, "Tabs (index | title | address | what the page says):\n" + body + "\n")
99+def build_labels_prompt(payload, strict=False):
100+ rules = [
101+ f"- Propose at most {payload.get('maxGroups', 8)} new topic groups for tabs that do not fit an existing group.",
102+ "- Each needs a label of 2 to 4 words in sentence case, one emoji and one color, at the same position in labels, emojis and colors.",
103+ f"- Emoji from: {EMOJI_LIST}",
104+ f"- Color from: {', '.join(COLORS)}.",
105+ "- Group by topic or task, not by website. Write labels in the language most tab titles use.",
106+ ]
107+ if strict:
108+ rules.append(f"- {STRICT_LINE}")
109+ return (
110+ "You plan topic groups for browser tabs.\n\nRules:\n" + "\n".join(rules) + "\n\n"
111+ + _existing_block(payload.get("existingGroups") or [])
112+ + "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
113+ )
114+
115+
116+def build_assign_prompt(payload, labels, strict=False):
117+ existing = payload.get("existingGroups") or []
118+ options = [f"{n} | {_clean(g.get('title'), 60)}" for n, g in enumerate(existing)]
119+ options += [f"{len(existing) + n} | {_clean(label, 60)}" for n, label in enumerate(labels)]
120+ rules = [
121+ f"- assignment has exactly {len(payload['items'])} numbers, one per tab in index order.",
122+ "- Each number is the group that tab belongs to, or -1 if it fits none.",
123+ ]
124+ if strict:
125+ rules.append(f"- {STRICT_LINE}")
126+ return (
127+ "You assign browser tabs to groups.\n\nRules:\n" + "\n".join(rules) + "\n\n"
128+ "Groups (number | title):\n" + "\n".join(options) + "\n\n"
129+ "Tabs (index | title | address | description):\n" + render_items(payload["items"]) + "\n"
130+ )
host/tests/fake_fm.py+3−29
@@ -5,12 +5,8 @@ control.json keys:
5 respond_queue: list of the same, consumed one per call before `respond` is used
5 respond_queue: list of the same, consumed one per call before `respond` is used
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
6 available: {"stdout": str, "stderr": str, "exit": int} for `fm available`
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
7 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
8− schema_fail: true → every `fm schema` fails
9− count_tokens: {"stdout": str, "exit": int} for `fm count-tokens` (default: stdin length / 4)
10 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
8 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
11−Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
9+Every invocation's argv is appended to argv.log as one JSON line.
12−(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
13−--model is "system".
14"""
10"""
15import json
11import json
16import os
12import os
@@ -31,38 +27,16 @@ def main():
31 path = os.path.join(HERE, "control.json")
27 path = os.path.join(HERE, "control.json")
32 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
28 control = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else {}
33 cmd = argv[0] if argv else ""
29 cmd = argv[0] if argv else ""
34− if cmd == "license":
35− # Like `sudo fm license`: show the terms, then agree or decline on stdin.
36− sys.stdout.write("APPLE FOUNDATION MODELS CLI LEGAL NOTICE & TERMS\n...\nType 'agree' to accept: ")
37− sys.stdout.flush()
38− agreed = sys.stdin.readline().strip().lower() == "agree"
39− if agreed:
40− control["license"] = False
41− json.dump(control, open(path, "w", encoding="utf-8"))
42− spec = {"exit": 0 if agreed else 1}
43− elif control.get("license"):
30+ if control.get("license"):
44 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
31 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})
49 elif cmd == "available":
32 elif cmd == "available":
50 spec = control.get("available", {"stdout": "available", "exit": 0})
33 spec = control.get("available", {"stdout": "available", "exit": 0})
51− elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
52− spec = {"stderr": f"Error: The value '{argv[argv.index('--model') + 1]}' is invalid for '--model <model>'. Please provide one of 'system'.\n", "exit": 64}
53 elif cmd == "schema":
34 elif cmd == "schema":
54− if control.get("schema_fail"):
55− spec = {"stderr": "error: schema generation failed", "exit": 1}
56− elif "--object" in argv and control.get("schema_nested", True) is False:
35+ if "--object" in argv and control.get("schema_nested", True) is False:
57 spec = {"stderr": "error: unknown option --object", "exit": 64}
36 spec = {"stderr": "error: unknown option --object", "exit": 64}
58− elif "--object" in argv and argv[argv.index("--object") + 2:argv.index("--object") + 3] != ["--schema"]:
59− name = argv[argv.index("--object") + 1]
60− spec = {"stderr": f"Error: SchemaError(errorDescription: Optional(\"--object '{name}' must be followed by --schema\"))\n", "exit": 1}
61 else:
37 else:
62 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
38 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
63 elif cmd == "respond":
39 elif cmd == "respond":
64− with open(os.path.join(HERE, "stdin.log"), "a", encoding="utf-8") as f:
65− f.write(json.dumps(sys.stdin.read()) + "\n")
66 queue = control.get("respond_queue") or []
40 queue = control.get("respond_queue") or []
67 if queue:
41 if queue:
68 spec = queue.pop(0)
42 spec = queue.pop(0)
host/tests/golden/labels.jsondeleted−102
@@ -1,102 +0,0 @@
1−[
2− [
3− "\"Rust async runtimes.\"",
4− "Rust async runtimes"
5− ],
6− [
7− "rust async runtimes",
8− "Rust async runtimes"
9− ],
10− [
11− "Rust tabs and docs",
12− "Rust and docs"
13− ],
14− [
15− "One two three four five six",
16− "One two three four"
17− ],
18− [
19− "Machine learning research papers reading list",
20− "Machine learning research"
21− ],
22− [
23− "iPhone camera tips",
24− "iPhone camera tips"
25− ],
26− [
27− "macOS updates",
28− "macOS updates"
29− ],
30− [
31− "Children's books",
32− "Children's books"
33− ],
34− [
35− "'Quoted' title words",
36− "Quoted title words"
37− ],
38− [
39− "Planning a trip to Japan",
40− "Planning a trip"
41− ],
42− [
43− "Recipes for",
44− null
45− ],
46− [
47− "Python教程",
48− "Python教程"
49− ],
50− [
51− "React 教程",
52− "React 教程"
53− ],
54− [
55− "日本旅行",
56− "日本旅行"
57− ],
58− [
59− "京都の旅行計画",
60− "京都の旅行計画"
61− ],
62− [
63− "日本旅行。",
64− "日本旅行"
65− ],
66− [
67− "这是一个非常非常非常非常非常长的标题名字",
68− "这是一个非常非常非常非常非常长的"
69− ],
70− [
71− "Kubernetes 集群部署与运维指南 最佳实践",
72− "Kubernetes 集群部署与运维指南"
73− ],
74− [
75− "ทริปญี่ปุ่น",
76− "ทริปญี่ปุ่น"
77− ],
78− [
79− "日",
80− null
81− ],
82− [
83− "Tabs",
84− null
85− ],
86− [
87− "Misc stuff",
88− null
89− ],
90− [
91− "",
92− null
93− ],
94− [
95− "x",
96− null
97− ],
98− [
99− "Supercalifragilisticexpialidocious extraordinarily",
100− null
101− ]
102−]
host/tests/golden/name-24.txt+1−5
@@ -1,4 +1,3 @@
1−[instructions]
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
3
2
4Rules:
3Rules:
@@ -7,11 +6,8 @@ Rules:
7- Never use the words tab, tabs, group, misc, various, stuff.
6- Never use the words tab, tabs, group, misc, various, stuff.
8- Write the title in the language most tab titles use.
7- Write the title in the language most tab titles use.
9- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
10−- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11−- Never reuse a name listed under other groups or not allowed.
12
9
13−[stdin]
14−Tabs (index | title | address | what the page says):
10+Tabs (index | title | address | description):
150 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
110 | tokio 0 - docs.rs | docs.rs/tokio/latest/tokio/?search=x0&utm_source=y | Documentation for the tokio crate, part 0.
161 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
121 | serde 1 - docs.rs | docs.rs/serde/latest/serde/?search=x1&utm_source=y | Documentation for the serde crate, part 1.
172 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
132 | axum 2 - docs.rs | docs.rs/axum/latest/axum/?search=x2&utm_source=y | Documentation for the axum crate, part 2.
host/tests/golden/name-3.txt+3−8
@@ -1,4 +1,3 @@
1−[instructions]
2You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
1You name a browser tab group. Reply with a title and one emoji that describe what these tabs have in common.
3
2
4Rules:
3Rules:
@@ -7,14 +6,10 @@ Rules:
7- Never use the words tab, tabs, group, misc, various, stuff.
6- Never use the words tab, tabs, group, misc, various, stuff.
8- Write the title in the language most tab titles use.
7- Write the title in the language most tab titles use.
9- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
8- Emoji: exactly one, chosen from this list: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄
10−- If a current title is given, keep it exactly if it still fits; change it only if the tabs have clearly moved on.
9+- Current title: "Rust async". Keep it exactly if it still fits; change it only if the tabs have clearly moved on.
11−- Never reuse a name listed under other groups or not allowed.
10+- Other groups in this window are named: "Lisbon trip". Do not reuse those names.
12
11
13−[stdin]
14−Current title: "Rust async"
15−Other groups in this window: "Lisbon trip"
16−
17−Tabs (index | title | address | what the page says):
12+Tabs (index | title | address | description):
180 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
130 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
191 | async-std | async.rs | Async version of the Rust standard library.
141 | async-std | async.rs | Async version of the Rust standard library.
202 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
152 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/organize-3.txtnew file+17
@@ -0,0 +1,17 @@
1+You sort browser tabs into topic groups.
2+
3+Rules:
4+- Put each tab index into exactly one group, or into leftovers if it fits nowhere.
5+- A group needs at least 2 tabs. Prefer fewer, broader groups; at most 1 new groups.
6+- Reuse an existing group when a tab fits it: set "existing" to that group's number and leave title, emoji and color empty.
7+- New groups: title of 2 to 4 words in sentence case, one emoji from: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄, one color from: grey, blue, red, yellow, green, pink, purple, cyan, orange.
8+- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
9+- Write titles in the language most tab titles use.
10+
11+Existing groups (number | title | examples):
12+0 | Lisbon trip | Hotels; Flights
13+
14+Tabs (index | title | address | description):
15+0 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
16+1 | async-std | async.rs | Async version of the Rust standard library.
17+2 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
host/tests/golden/topics-3.txtdeleted−17
@@ -1,17 +0,0 @@
1−[instructions]
2−You label browser tabs with broad topics so that related tabs can be grouped.
3−
4−Rules:
5−- Give every listed index exactly one topic of one or two words.
6−- Keep topics as general as possible, for example Programming, Travel, Cooking, Shopping, News, Finance, Music, Health, Work.
7−- Use exactly the same topic words for tabs that belong together.
8−- Group by what the page is about, not by website.
9−- Write topics in English.
10−
11−[stdin]
12−Tabs (index | title | address | what the page says):
13−0 | Tokio - An asynchronous Rust runtime | tokio.rs | Tokio is an asynchronous runtime for Rust.
14−1 | async-std | async.rs | Async version of the Rust standard library.
15−2 | Asynchronous Programming in Rust | rust-lang.github.io/async-book/ | -
16−3 | Hotels | - | -
17−4 | Flights | - | -
host/tests/support.py+1−10
@@ -60,11 +60,9 @@ class HostCase(unittest.TestCase):
60 os.chmod(self.fm, 0o755)
60 os.chmod(self.fm, 0o755)
61 self.support = os.path.join(self.tmp, "support")
61 self.support = os.path.join(self.tmp, "support")
62 os.makedirs(os.path.join(self.support, "schemas"))
62 os.makedirs(os.path.join(self.support, "schemas"))
63− for name in ("name.json", "topics.json"):
63+ for name in ("name.json", "organize.json"):
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
64 with open(os.path.join(self.support, "schemas", name), "w") as f:
65 f.write("{}")
65 f.write("{}")
66− with open(os.path.join(self.support, "schemas", "version"), "w") as f:
67− f.write(host.SCHEMA_VERSION + "\n")
68 self.control({})
66 self.control({})
69 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
67 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
70 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
68 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
@@ -85,13 +83,6 @@ class HostCase(unittest.TestCase):
85 return []
83 return []
86 return [json.loads(line) for line in open(path, encoding="utf-8")]
84 return [json.loads(line) for line in open(path, encoding="utf-8")]
87
85
88− def stdin_log(self):
89− """The prompts `fm respond` read on stdin, in call order."""
90− path = os.path.join(self.tmp, "stdin.log")
91− if not os.path.exists(path):
92− return []
93− return [json.loads(line) for line in open(path, encoding="utf-8")]
94−
95 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
86 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
96 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
87 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
97 argv = args if args is not None else [origin]
88 argv = args if args is not None else [origin]
host/tests/test_host.py+45−324
@@ -2,13 +2,10 @@ import io
2import json
2import json
3import os
3import os
4import re
4import re
5−import shutil
6import struct
5import struct
7−import subprocess
8−import sys
9import unittest
6import unittest
10
7
11−from support import HOST_DIR, HOST_SCRIPT, ITEMS3, HostCase, frame, host, unframe
8+from support import HOST_DIR, ITEMS3, HostCase, frame, host, unframe
12
9
13
10
14class Framing(HostCase):
11class Framing(HostCase):
@@ -55,17 +52,6 @@ class Origin(HostCase):
55 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
52 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
56 self.assertTrue(reply["ok"])
53 self.assertTrue(reply["ok"])
57
54
58− def test_store_origin_is_accepted_once_set(self):
59− store = "chrome-extension://" + "b" * 32 + "/"
60− self.assertFalse(host.origin_ok(store))
61− saved, host.STORE_ORIGIN = host.STORE_ORIGIN, store
62− try:
63− self.assertTrue(host.origin_ok(store))
64− self.assertTrue(host.origin_ok(host.ALLOWED_ORIGIN))
65− self.assertEqual(host.manifest_for("/x/diagonal-host")["allowed_origins"], [host.ALLOWED_ORIGIN, store])
66− finally:
67− host.STORE_ORIGIN = saved
68−
69 def test_origin_is_pinned(self):
55 def test_origin_is_pinned(self):
70 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
56 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
71
57
@@ -77,7 +63,7 @@ class Ping(HostCase):
77 self.assertEqual(r["result"]["fmPath"], self.fm)
63 self.assertEqual(r["result"]["fmPath"], self.fm)
78 self.assertTrue(r["result"]["fmAvailable"])
64 self.assertTrue(r["result"]["fmAvailable"])
79 self.assertTrue(r["result"]["schemasOk"])
65 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "topics")
66+ self.assertEqual(r["result"]["organizeMode"], "nested")
81
67
82 def test_ping_model_unavailable(self):
68 def test_ping_model_unavailable(self):
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
69 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -92,22 +78,13 @@ class Ping(HostCase):
92 self.assertTrue(r["result"]["licenseRequired"])
78 self.assertTrue(r["result"]["licenseRequired"])
93 self.assertIn("sudo fm license", r["result"]["fmMessage"])
79 self.assertIn("sudo fm license", r["result"]["fmMessage"])
94
80
95− def test_ping_says_why_schemas_could_not_be_written(self):
96− for f in os.listdir(os.path.join(self.support, "schemas")):
97− os.remove(os.path.join(self.support, "schemas", f))
98− self.control({"schema_fail": True})
99− r = self.call("ping")
100− self.assertTrue(r["ok"], r)
101− self.assertFalse(r["result"]["schemasOk"])
102− self.assertIn("fm could not write", r["result"]["schemaMessage"])
103−
104 def test_ping_writes_missing_schemas_once_fm_works(self):
81 def test_ping_writes_missing_schemas_once_fm_works(self):
105 for f in os.listdir(os.path.join(self.support, "schemas")):
82 for f in os.listdir(os.path.join(self.support, "schemas")):
106 os.remove(os.path.join(self.support, "schemas", f))
83 os.remove(os.path.join(self.support, "schemas", f))
107 r = self.call("ping")
84 r = self.call("ping")
108 self.assertTrue(r["ok"], r)
85 self.assertTrue(r["ok"], r)
109 self.assertTrue(r["result"]["schemasOk"])
86 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "topics.json"])
87+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
111
88
112 def test_ping_without_fm(self):
89 def test_ping_without_fm(self):
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
90 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,12 +101,10 @@ class Name(HostCase):
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
101 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125 self.assertGreater(r["meta"]["promptChars"], 0)
102 self.assertGreater(r["meta"]["promptChars"], 0)
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
103 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:8], ["respond", "--model", "system", "--no-stream", "--greedy", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
104+ self.assertEqual(argv[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− self.assertIn("You name a browser tab group", argv[8])
105+ self.assertEqual(argv[6], "--")
129− self.assertEqual(len(argv), 9)
130− # Tab text goes on stdin, never into argv where `ps` would show it.
131− self.assertIn("tokio.rs", self.stdin_log()[0])
106+ self.assertIn("tokio.rs", argv[7])
132− self.assertNotIn("tokio", " ".join(argv))
107+ self.assertEqual(len(argv), 8)
133
108
134 def test_json_wrapped_in_prose_and_fence(self):
109 def test_json_wrapped_in_prose_and_fence(self):
135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
110 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -142,34 +117,16 @@ class Name(HostCase):
142 r = self.call("name", {"items": items})
117 r = self.call("name", {"items": items})
143 self.assertEqual(r["result"]["emoji"], "💻")
118 self.assertEqual(r["result"]["emoji"], "💻")
144
119
145− def test_pcc_falls_back_to_the_on_device_model(self):
120+ def test_pcc_only_when_asked(self):
146− # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
147 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
121 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
148− r = self.call("name", {"items": ITEMS3}, model="pcc")
122+ self.call("name", {"items": ITEMS3}, model="pcc")
149− self.assertTrue(r["ok"], r)
150 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
123 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
151− self.assertEqual(argv[1:3], ["--model", "system"])
124+ self.assertEqual(argv[1:3], ["--model", "pcc"])
152−
153− def test_guardrail_refusal_is_retried_permissively_once(self):
154− self.control({"respond_queue": [
155− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
156− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
157− ]})
158− r = self.call("name", {"items": ITEMS3})
159− self.assertTrue(r["ok"], r)
160− calls = [a for a in self.argv_log() if a[0] == "respond"]
161− self.assertEqual(len(calls), 2)
162− self.assertNotIn("--guardrails", calls[0])
163− self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
164−
165− def test_context_overflow_is_over_budget(self):
166− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
167− self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
168
125
169 def test_prompt_never_contains_tab_ids(self):
126 def test_prompt_never_contains_tab_ids(self):
170 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
127 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
171 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
128 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
172− prompt = self.stdin_log()[0]
129+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
173 self.assertNotIn("987654", prompt)
130 self.assertNotIn("987654", prompt)
174
131
175
132
@@ -203,52 +160,12 @@ class ErrorCodes(HostCase):
203 def test_over_budget_from_fm(self):
160 def test_over_budget_from_fm(self):
204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
161 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
205
162
206− def test_page_text_is_shortened_before_giving_up(self):
207− # 40 tabs with long page text don't fit at 500 chars each, but do once the text is shortened.
208− items = [{"title": f"Page {n}", "url": f"https://example.com/{n}", "description": "word " * 100} for n in range(40)]
209− self.respond(json.dumps({"title": "Reading list", "emoji": "📚"}))
210− r = self.call("name", {"items": items})
211− self.assertTrue(r["ok"], r)
212− body = self.stdin_log()[0]
213− self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214− self.assertIn("word", body)
215−
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−
246 def test_over_budget_precheck_reports_allowed_items(self):
163 def test_over_budget_precheck_reports_allowed_items(self):
247− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
164+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
248 r = self.call("name", {"items": big})
165 r = self.call("name", {"items": big})
249 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
166 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
250 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
167 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
251− self.assertLess(r["error"]["allowedItems"], 120)
168+ self.assertLess(r["error"]["allowedItems"], 30)
252 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
169 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
253
170
254 def test_guardrail(self):
171 def test_guardrail(self):
@@ -271,7 +188,6 @@ class ErrorCodes(HostCase):
271
188
272 def test_schema_missing(self):
189 def test_schema_missing(self):
273 os.remove(os.path.join(self.support, "schemas", "name.json"))
190 os.remove(os.path.join(self.support, "schemas", "name.json"))
274− self.control({"schema_fail": True}) # first-use repair fails too
275 r = self.call("name", {"items": ITEMS3})
191 r = self.call("name", {"items": ITEMS3})
276 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
192 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
277
193
@@ -283,21 +199,8 @@ class ErrorCodes(HostCase):
283 def test_strict_retry_appends_line(self):
199 def test_strict_retry_appends_line(self):
284 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
200 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
285 self.call("name", {"items": ITEMS3}, strict=True)
201 self.call("name", {"items": ITEMS3}, strict=True)
286− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
202+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
287− self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
203+ self.assertIn("Reply with only the object", prompt)
288−
289−
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
204
302
205
303class Organize(HostCase):
206class Organize(HostCase):
@@ -305,166 +208,55 @@ class Organize(HostCase):
305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
208 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
306 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
209 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
307 {"title": "Random page", "url": "https://example.org/"},
210 {"title": "Random page", "url": "https://example.org/"},
308− {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
309− {"title": "Rice recipe", "url": "https://cooking.example/rice"},
310 ]
211 ]
311− EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
312
212
313− def topics(self, pairs):
213+ def test_nested_path(self):
314− self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
214+ self.respond(json.dumps({"groups": [
315−
215+ {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
316− def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
216+ {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
317− # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
217+ ], "leftovers": [5]}))
318− self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
319− (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
320− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
218+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
321 self.assertTrue(r["ok"], r)
219 self.assertTrue(r["ok"], r)
322− self.assertEqual(r["meta"]["path"], "topics")
323− groups = r["result"]["groups"]
324− self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
220+ self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
325− new = sorted(g["members"] for g in groups[1:])
221+ self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
326− self.assertEqual(new, [[0, 1, 2], [6, 7]])
327− self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
328 self.assertEqual(r["result"]["leftovers"], [5])
222 self.assertEqual(r["result"]["leftovers"], [5])
223+ self.assertEqual(r["meta"]["path"], "nested")
329
224
330− def test_new_groups_are_named_right_away(self):
225+ def test_two_call_fallback(self):
331− self.control({"respond_queue": [
226+ os.remove(os.path.join(self.support, "schemas", "organize.json"))
332− {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
227+ for name in ("organize-labels.json", "organize-assign.json"):
333− [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
228+ open(os.path.join(self.support, "schemas", name), "w").write("{}")
334− {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
335− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
336− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
337− ]})
338− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
339− self.assertTrue(r["ok"], r)
340− by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
341− self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
342− self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
343− name_calls = self.stdin_log()[1:]
344− self.assertIn("Tokio", name_calls[0])
345− self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346−
347− def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348 self.control({"respond_queue": [
229 self.control({"respond_queue": [
349− {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
230+ {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
350− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
231+ {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
351 ]})
232 ]})
352− r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
233+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
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)
234 self.assertTrue(r["ok"], r)
372− self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
235+ self.assertEqual(r["meta"]["path"], "two-call")
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]
236+ groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
237+ self.assertEqual(groups[0]["members"], [3, 4])
379− self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
238+ self.assertEqual(groups[-1]["title"], "Rust async")
380−
239+ self.assertEqual(groups[-1]["members"], [0, 1, 2])
381− def test_fm_answers_greedily(self):
240+ self.assertEqual(r["result"]["leftovers"], [5])
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])
241+ prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
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)
242+ self.assertEqual(len(prompts_sent), 2)
391−
243+ self.assertIn("1 | Rust async", prompts_sent[1])
392− def test_a_topic_of_one_tab_is_a_leftover(self):
393− self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
394− r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
395− self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
396− self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
397−
398− def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
399− self.topics([(0, "Programming")])
400− self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
401− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
402− instructions = argv[argv.index("-i") + 1]
403− self.assertIn("broad topics", instructions)
404− self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
405− body = self.stdin_log()[0]
406− self.assertIn("4 | Lisbon travel guide", body)
407− self.assertIn("8 | Flights to Lisbon | - | -", body)
408−
409− def test_no_topics_is_bad_output(self):
410− self.respond(json.dumps({"tabs": []}))
411− r = self.call("organize", {"items": self.ITEMS})
412− self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
413−
414− def test_context_overflow_suggests_a_smaller_batch(self):
415− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
416− r = self.call("organize", {"items": self.ITEMS})
417− self.assertEqual(r["error"]["code"], "OVER_BUDGET")
418− self.assertEqual(r["error"]["allowedItems"], 5)
419
244
420
245
421class InstallSchemas(HostCase):
246class InstallSchemas(HostCase):
422− def test_writes_name_and_topics(self):
247+ def test_nested_supported(self):
423 for f in os.listdir(os.path.join(self.support, "schemas")):
248 for f in os.listdir(os.path.join(self.support, "schemas")):
424 os.remove(os.path.join(self.support, "schemas", f))
249 os.remove(os.path.join(self.support, "schemas", f))
425 code, _, p = self.run_host(args=["--install-schemas"])
250 code, _, p = self.run_host(args=["--install-schemas"])
426 self.assertEqual(code, 0, p.stderr)
251 self.assertEqual(code, 0, p.stderr)
427− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
252+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
428
253
429− def test_nested_object_gets_its_schema_from_fm_first(self):
254+ def test_falls_back_to_two_flat_schemas(self):
430− # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
255+ self.control({"schema_nested": False})
431− for f in os.listdir(os.path.join(self.support, "schemas")):
432− os.remove(os.path.join(self.support, "schemas", f))
433 code, _, p = self.run_host(args=["--install-schemas"])
256 code, _, p = self.run_host(args=["--install-schemas"])
434 self.assertEqual(code, 0, p.stderr)
257 self.assertEqual(code, 0, p.stderr)
435− calls = [a for a in self.argv_log() if a[0] == "schema"]
436− self.assertTrue(any("TabTopic" in a for a in calls))
437− topics = next(a for a in calls if "Topics" in a)
438− i = topics.index("--object")
439− self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
440− self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
441− self.assertEqual(topics[i + 4], "--array")
442−
443− def test_schemas_from_an_older_host_are_replaced(self):
444− d = os.path.join(self.support, "schemas")
258+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
445− for f in os.listdir(d):
446− os.remove(os.path.join(d, f))
447− for name in ("name.json", "organize-labels.json", "organize-assign.json"):
448− open(os.path.join(d, name), "w").write("{}")
449− r = self.call("ping")
450− self.assertTrue(r["result"]["schemasOk"], r)
451− self.assertEqual(r["result"]["organizeMode"], "topics")
452− self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
453
259
454− def test_first_use_writes_missing_schemas(self):
455− for f in os.listdir(os.path.join(self.support, "schemas")):
456− os.remove(os.path.join(self.support, "schemas", f))
457− self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
458− r = self.call("name", {"items": ITEMS3})
459− self.assertTrue(r["ok"], r)
460− self.assertIn("name.json", os.listdir(os.path.join(self.support, "schemas")))
461−
462− def test_first_use_without_the_terms_says_so(self):
463− for f in os.listdir(os.path.join(self.support, "schemas")):
464− os.remove(os.path.join(self.support, "schemas", f))
465− self.control({"license": True})
466− r = self.call("name", {"items": ITEMS3})
467− self.assertEqual(r["error"]["code"], "LICENSE_REQUIRED", r)
468
260
469 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
261 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
470 for f in os.listdir(os.path.join(self.support, "schemas")):
262 for f in os.listdir(os.path.join(self.support, "schemas")):
@@ -479,70 +271,6 @@ class InstallSchemas(HostCase):
479 self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
271 self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
480
272
481
273
482−class Register(HostCase):
483− def make_profile(self, rel, browsing=True):
484− d = os.path.join(self.tmp, "appsupport", rel)
485− os.makedirs(os.path.join(d, "Default"))
486− open(os.path.join(d, "Local State"), "w").write("{}")
487− if browsing:
488− open(os.path.join(d, "Default", "History"), "w").write("")
489− return d
490−
491− def setUp(self):
492− super().setUp()
493− self.env["DIAGONAL_APP_SUPPORT"] = os.path.join(self.tmp, "appsupport")
494− self.host_copy = os.path.join(self.tmp, "bin", "diagonal-host.py")
495− os.makedirs(os.path.dirname(self.host_copy))
496− shutil.copy(HOST_SCRIPT, self.host_copy)
497− for f in ("prompts.py", "validate.py", "emoji.txt"):
498− shutil.copy(os.path.join(HOST_DIR, f), os.path.dirname(self.host_copy))
499−
500− def run_copy(self, *args):
501− return subprocess.run([sys.executable, self.host_copy, *args], capture_output=True, text=True, env=self.env, timeout=30)
502−
503− def test_registers_with_every_chromium_browser_and_only_those(self):
504− origin = self.make_profile("BraveSoftware/Brave-Origin")
505− chrome = self.make_profile("Google/Chrome")
506− arc = self.make_profile("Arc/User Data")
507− unknown = self.make_profile("SomeVendor/NewBrowser")
508− electron = self.make_profile("Slack", browsing=False)
509− p = self.run_copy("--register", self.host_copy)
510− self.assertEqual(p.returncode, 0, p.stderr)
511− for d in (origin, chrome, arc, unknown):
512− m = json.load(open(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
513− self.assertEqual(m["path"], self.host_copy)
514− self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
515− self.assertFalse(os.path.exists(os.path.join(electron, "NativeMessagingHosts")))
516− self.assertIn("registered: BraveSoftware/Brave-Origin", p.stdout)
517−
518− p = self.run_copy("--unregister")
519− self.assertEqual(p.returncode, 0, p.stderr)
520− for d in (origin, chrome, arc, unknown):
521− self.assertFalse(os.path.exists(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
522−
523− def test_names_a_host_that_cannot_run(self):
524− self.make_profile("Google/Chrome")
525− os.chmod(self.host_copy, 0o644)
526− p = self.run_copy("--register", self.host_copy)
527− self.assertEqual(p.returncode, 1)
528− self.assertIn("missing or not executable", p.stderr)
529−
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−
539− def test_no_browser_found(self):
540− os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541− p = self.run_copy("--register", self.host_copy)
542− self.assertEqual(p.returncode, 1)
543− self.assertIn("No Chromium browser found", p.stderr)
544−
545−
546class SelfTest(HostCase):
274class SelfTest(HostCase):
547 def test_selftest_passes_with_a_working_fm(self):
275 def test_selftest_passes_with_a_working_fm(self):
548 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
276 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
@@ -557,13 +285,6 @@ class SelfTest(HostCase):
557 self.assertEqual(code, 1)
285 self.assertEqual(code, 1)
558 self.assertIn("FAIL model available", p.stdout.decode())
286 self.assertIn("FAIL model available", p.stdout.decode())
559
287
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−
567 def test_selftest_names_the_license_fix(self):
288 def test_selftest_names_the_license_fix(self):
568 self.control({"license": True})
289 self.control({"license": True})
569 code, _, p = self.run_host(args=["--selftest"])
290 code, _, p = self.run_host(args=["--selftest"])
host/tests/test_prompts_validate.py+6−15
@@ -1,4 +1,3 @@
1−import json
2import os
1import os
3import unittest
2import unittest
4
3
@@ -18,8 +17,7 @@ def items24():
18
17
19
18
20class Golden(unittest.TestCase):
19class Golden(unittest.TestCase):
21− def check(self, name, p):
20+ def check(self, name, text):
22− text = "[instructions]\n" + p.instructions + "\n\n[stdin]\n" + p.text
23 path = os.path.join(GOLDEN, name)
21 path = os.path.join(GOLDEN, name)
24 if UPDATE or not os.path.exists(path):
22 if UPDATE or not os.path.exists(path):
25 os.makedirs(GOLDEN, exist_ok=True)
23 os.makedirs(GOLDEN, exist_ok=True)
@@ -29,16 +27,16 @@ class Golden(unittest.TestCase):
29 def test_name_3(self):
27 def test_name_3(self):
30 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
28 p = prompts.build_name_prompt({"items": ITEMS3, "currentTitle": "Rust async", "siblingTitles": ["Lisbon trip"]})
31 self.check("name-3.txt", p)
29 self.check("name-3.txt", p)
32− self.assertLessEqual(prompts.size(p), 24_000)
30+ self.assertLessEqual(len(p), 10_000)
33
31
34 def test_name_24(self):
32 def test_name_24(self):
35 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
33 p = prompts.build_name_prompt({"items": items24(), "siblingTitles": []})
36 self.check("name-24.txt", p)
34 self.check("name-24.txt", p)
37− self.assertLessEqual(prompts.size(p), 24_000)
35+ self.assertLessEqual(len(p), 10_000)
38
36
39 def test_organize(self):
37 def test_organize(self):
40− p = prompts.build_topics_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
38+ p = prompts.build_organize_prompt({"items": ITEMS3, "existingGroups": [{"g": 0, "title": "Lisbon trip", "samples": ["Hotels", "Flights"]}], "maxGroups": 1})
41− self.check("topics-3.txt", p)
39+ self.check("organize-3.txt", p)
42
40
43
41
44class Rendering(unittest.TestCase):
42class Rendering(unittest.TestCase):
@@ -48,7 +46,7 @@ class Rendering(unittest.TestCase):
48 idx, title, addr, desc = line.split(" | ")
46 idx, title, addr, desc = line.split(" | ")
49 self.assertLessEqual(len(title), 120)
47 self.assertLessEqual(len(title), 120)
50 self.assertLessEqual(len(addr), 160)
48 self.assertLessEqual(len(addr), 160)
51− self.assertLessEqual(len(desc), 500)
49+ self.assertLessEqual(len(desc), 300)
52 self.assertTrue(addr.startswith("example.com/"))
50 self.assertTrue(addr.startswith("example.com/"))
53
51
54 def test_address(self):
52 def test_address(self):
@@ -74,13 +72,6 @@ class ValidateName(unittest.TestCase):
74 with self.assertRaises(validate.ValidationError, msg=bad):
72 with self.assertRaises(validate.ValidationError, msg=bad):
75 self.v(bad)
73 self.v(bad)
76
74
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−
84 def test_emoji_default(self):
75 def test_emoji_default(self):
85 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
76 self.assertEqual(self.v("Rust async", "not-an-emoji")["emoji"], "🧭")
86 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
77 self.assertEqual(self.v("Rust async", "✈")["emoji"], "✈️")
host/validate.py+34−138
@@ -7,7 +7,8 @@ from urllib.parse import urlsplit
7HERE = os.path.dirname(os.path.realpath(__file__))
7HERE = os.path.dirname(os.path.realpath(__file__))
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
8COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
9BANNED_WORDS = {"tab", "tabs", "group", "groups", "misc", "various", "stuff"}
10+QUOTES = "\"'“”‘’«»`"
10−TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—。、,!?:;]+$")
11+TRAILING_PUNCT = re.compile(r"[\s.,;:!?…\-–—]+$")
11
12
12
13
13class ValidationError(Exception):
14class ValidationError(Exception):
@@ -88,82 +89,34 @@ def default_emoji(items):
88 return CATEGORIES[category_for_host(majority)][0]
89 return CATEGORIES[category_for_host(majority)][0]
89
90
90
91
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−
97def repair_label(raw):
92def repair_label(raw):
98− """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars (16 wide characters
93+ """Trim, drop quotes, banned words and trailing punctuation, cap at 4 words / 28 chars, capitalise."""
99− for no-space scripts), drop a dangling "to"/"of", capitalise. Kept in step with src/shared/label.ts."""
100 if not isinstance(raw, str):
94 if not isinstance(raw, str):
101 return None
95 return None
102− s = _QUOTE.sub("", raw)
96+ s = raw.translate({ord(q): None for q in QUOTES})
103 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
97 s = TRAILING_PUNCT.sub("", " ".join(s.split()))
104 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
98 words = [w for w in s.split(" ") if w and re.sub(r"[^\w]", "", w.lower()) not in BANNED_WORDS]
105 words = words[:4]
99 words = words[:4]
106− if _no_space(" ".join(words)):
107− while len(words) > 1 and _width(" ".join(words)) > 16:
108− words.pop()
109− if _width(" ".join(words)) > 16:
110− words = [_cut_to_width(" ".join(words), 16)]
111− else:
112− while len(words) > 2 and len(" ".join(words)) > 28:
100+ while len(words) > 2 and len(" ".join(words)) > 28:
113− words.pop()
114− while len(words) > 1 and words[-1].lower() in DANGLING:
115 words.pop()
101 words.pop()
116 label = TRAILING_PUNCT.sub("", " ".join(words))
102 label = TRAILING_PUNCT.sub("", " ".join(words))
117 if not label:
103 if not label:
118 return None
104 return None
119− # "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
120− if not any(c.isupper() for c in words[0][1:]):
121− label = label[0].upper() + label[1:]
105+ label = label[0].upper() + label[1:]
122 return label if is_valid_label(label) else None
106 return label if is_valid_label(label) else None
123
107
124
108
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−
151def is_valid_label(label):
109def is_valid_label(label):
152 words = label.split()
110 words = label.split()
153− wide = _no_space(label)
154 return (
111 return (
155− (2 <= len(label) <= 28 and _width(label) <= 16 if wide else 3 <= len(label) <= 28)
112+ 3 <= len(label) <= 28
156− and (1 <= len(words) <= 4 if wide else 2 <= len(words) <= 4)
113+ and 2 <= len(words) <= 4
157− and not _QUOTE.search(label)
114+ and not any(q in label for q in QUOTES)
158 and not TRAILING_PUNCT.search(label)
115 and not TRAILING_PUNCT.search(label)
159 and not any(w.lower() in BANNED_WORDS for w in words)
116 and not any(w.lower() in BANNED_WORDS for w in words)
160 )
117 )
161
118
162
119
163−def same_label(a, b):
164− return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165−
166−
167def validate_name(out, payload):
120def validate_name(out, payload):
168 if not isinstance(out, dict):
121 if not isinstance(out, dict):
169 raise ValidationError("reply is not an object")
122 raise ValidationError("reply is not an object")
@@ -208,15 +161,8 @@ def validate_organize(out, payload):
208 if not members:
161 if not members:
209 continue
162 continue
210 ex = g.get("existing")
163 ex = g.get("existing")
211− if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
212− ex = ex[0] if len(ex) == 1 else None
213 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
164 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
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})
165+ joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
220 continue
166 continue
221 new.append((members, g))
167 new.append((members, g))
222
168
@@ -241,80 +187,30 @@ def validate_organize(out, payload):
241 return {"groups": groups, "leftovers": sorted(set(leftovers))}
187 return {"groups": groups, "leftovers": sorted(set(leftovers))}
242
188
243
189
244−VAGUE_TOPICS = {"other", "others", "misc", "miscellaneous", "general", "various", "unknown", "none", "n/a", "-"}
190+def organize_from_two_calls(labels_out, assign_out, payload):
245−
191+ """Rebuild the one-call shape from the flat fallback replies, then validate it as usual."""
246−
192+ labels = [x for x in (labels_out or {}).get("labels") or [] if isinstance(x, str)]
247−def _norm_topic(t):
193+ emojis = (labels_out or {}).get("emojis") or []
248− t = " ".join(str(t or "").split()).strip(" .,:;!?\"'").casefold()
194+ colors = (labels_out or {}).get("colors") or []
249− return "" if t in VAGUE_TOPICS else t
195+ n_existing = len(payload.get("existingGroups") or [])
250−
196+ assignment = (assign_out or {}).get("assignment")
251−
252−def organize_from_topics(out, payload, owner):
253− """Tabs that share a topic form a group. A topic that an existing group's example tabs have joins that
254− group; others become new groups (titled later by the naming loop) or leftovers when too small.
255− `owner` maps the example tabs' indexes to their group number (prompts.topic_items)."""
256− if not isinstance(out, dict) or not isinstance(out.get("tabs"), list):
197+ if not isinstance(assignment, list):
257− raise ValidationError("tabs is not a list")
198+ raise ValidationError("assignment is not a list")
199+ buckets = {}
258− n_items = len(payload.get("items", []))
200+ for i, target in enumerate(assignment[: len(payload.get("items", []))]):
259− tab_topic, group_votes = {}, {}
201+ if isinstance(target, int) and not isinstance(target, bool) and 0 <= target < n_existing + len(labels):
260− for t in out["tabs"]:
261− if not isinstance(t, dict):
262− continue
263− i, topic = t.get("index"), _norm_topic(t.get("topic"))
264− if isinstance(i, bool) or not isinstance(i, int) or not topic:
265− continue
266− if 0 <= i < n_items:
267− tab_topic.setdefault(i, topic)
268− elif i in owner:
269− votes = group_votes.setdefault(topic, {})
270− votes[owner[i]] = votes.get(owner[i], 0) + 1
271− if not tab_topic:
272− if not any(isinstance(t, dict) and _in_range(t.get("index"), n_items) for t in out["tabs"]):
273− raise ValidationError("no tab got a topic")
274− # Every tab got only a vague topic ("Other", "Misc"): the model found nothing in common.
275− return validate_organize({"groups": [], "leftovers": []}, payload)
276− clusters = {}
277− for i in sorted(tab_topic):
278− clusters.setdefault(tab_topic[i], []).append(i)
202+ buckets.setdefault(target, []).append(i)
279 groups = []
203 groups = []
280− for topic, members in clusters.items():
204+ for target, members in sorted(buckets.items()):
281− g = _winner(group_votes.get(topic) or {}, members, payload)
205+ if target < n_existing:
282− if g is not None:
283− groups.append({"existing": g, "members": members})
206+ groups.append({"existing": target, "members": members})
284 else:
207 else:
208+ k = target - n_existing
209+ groups.append({
210+ "title": labels[k],
211+ "emoji": emojis[k] if k < len(emojis) else "",
212+ "color": colors[k] if k < len(colors) else "",
213+ "existing": -1,
285− groups.append({"title": "", "emoji": "", "color": "", "members": members})
214+ "members": members,
215+ })
286 return validate_organize({"groups": groups, "leftovers": []}, payload)
216 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
icons/128.png

Binary file not shown.

icons/16.png

Binary file not shown.

icons/32.png

Binary file not shown.

icons/48.png

Binary file not shown.

icons/icon.pngdeleted

Binary file not shown.

icons/icon.svgnew file+6
@@ -0,0 +1,6 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2+ <rect x="4" y="4" width="120" height="120" rx="28" fill="#1a7f5a"/>
3+ <path d="M64 22c-15 12-26 27-26 42 0 14 11 24 26 24s26-10 26-24c0-15-11-30-26-42z" fill="#a7f3c9"/>
4+ <path d="M64 44v62" stroke="#0d3b2a" stroke-width="7" stroke-linecap="round"/>
5+ <path d="M64 70l-12-10M64 82l13-11" stroke="#0d3b2a" stroke-width="6" stroke-linecap="round"/>
6+</svg>
package.json+2−3
@@ -12,9 +12,8 @@
12 "test:host": "cd host && python3 -m unittest discover -s tests -v",
12 "test:host": "cd host && python3 -m unittest discover -s tests -v",
13 "gen-emoji": "node scripts/gen-emoji.mjs",
13 "gen-emoji": "node scripts/gen-emoji.mjs",
14 "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
14 "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
15− "zip:store": "node scripts/build.mjs --store && cd dist-store && zip -qr ../diagonal-store.zip .",
16− "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs && node test/e2e/selfupdate.mjs",
15+ "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
17− "icons": "python3 scripts/render-icons.py",
16+ "icons": "node scripts/render-icons.mjs",
18 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
17 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
19 },
18 },
20 "devDependencies": {
19 "devDependencies": {
scripts/build.mjs+3−17
@@ -1,14 +1,11 @@
1// Bundles src/ into dist/ (the unpacked extension). `--watch` rebuilds on change.
1// Bundles src/ into dist/ (the unpacked extension). `--watch` rebuilds on change.
2−// `--store` writes dist-store/ for the Chrome Web Store instead: no pinned key (the store assigns the ID),
3−// no bundled host or installer, and a version without leading zeros.
4import * as esbuild from "esbuild";
2import * as esbuild from "esbuild";
5import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6import { dirname, join } from "node:path";
4import { dirname, join } from "node:path";
7import { fileURLToPath } from "node:url";
5import { fileURLToPath } from "node:url";
8
6
9const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7const root = join(dirname(fileURLToPath(import.meta.url)), "..");
10−const store = process.argv.includes("--store");
11−const dist = join(root, store ? "dist-store" : "dist");
8+const dist = join(root, "dist");
12const watch = process.argv.includes("--watch");
9const watch = process.argv.includes("--watch");
13
10
14rmSync(dist, { recursive: true, force: true });
11rmSync(dist, { recursive: true, force: true });
@@ -33,25 +30,14 @@ function copyStatic() {
33 if (!/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/.test(stamp)) throw new Error(`DIAGONAL_VERSION must be YYYY.MM.DD.HHMM, got ${stamp}`);
30 if (!/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/.test(stamp)) throw new Error(`DIAGONAL_VERSION must be YYYY.MM.DD.HHMM, got ${stamp}`);
34 manifest.version = stamp;
31 manifest.version = stamp;
35 }
32 }
36− if (store) {
37− delete manifest.key;
38− manifest.version = manifest.version.split(".").map((n) => String(Number(n))).join(".");
39− }
40 writeFileSync(join(dist, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
33 writeFileSync(join(dist, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
41 cpSync(join(root, "src/popup/popup.html"), join(dist, "popup.html"));
34 cpSync(join(root, "src/popup/popup.html"), join(dist, "popup.html"));
42 cpSync(join(root, "src/options/options.html"), join(dist, "options.html"));
35 cpSync(join(root, "src/options/options.html"), join(dist, "options.html"));
43 cpSync(join(root, "src/shared/ui.css"), join(dist, "ui.css"));
36 cpSync(join(root, "src/shared/ui.css"), join(dist, "ui.css"));
44− cpSync(join(root, "icons"), join(dist, "icons"), { recursive: true, filter: (p) => !p.endsWith("icon.png") });
37+ cpSync(join(root, "icons"), join(dist, "icons"), { recursive: true, filter: (p) => !p.endsWith(".svg") });
45− if (store) return; // the store package is the extension alone; the host comes from Homebrew
46 // The native host and its installer ride along, so the release zip is all a user needs.
38 // The native host and its installer ride along, so the release zip is all a user needs.
47 mkdirSync(join(dist, "host"));
39 mkdirSync(join(dist, "host"));
48− for (const f of ["prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(dist, "host", f));
40+ for (const f of ["diagonal-host.py", "prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(dist, "host", f));
49− // The host reports the same version as the extension it shipped with.
50− const hostSrc = readFileSync(join(root, "host/diagonal-host.py"), "utf8");
51− const stamped = hostSrc.replace(/^HOST_VERSION = "[^"]*"/m, `HOST_VERSION = "${manifest.version}"`);
52− if (stamped === hostSrc && !hostSrc.includes(`HOST_VERSION = "${manifest.version}"`)) throw new Error("HOST_VERSION line not found in host/diagonal-host.py");
53− writeFileSync(join(dist, "host/diagonal-host.py"), stamped);
54− chmodSync(join(dist, "host/diagonal-host.py"), 0o755);
55 cpSync(join(root, "extension-id"), join(dist, "extension-id"));
41 cpSync(join(root, "extension-id"), join(dist, "extension-id"));
56 cpSync(join(root, "scripts/install-manifest.sh"), join(dist, "install-host.command"));
42 cpSync(join(root, "scripts/install-manifest.sh"), join(dist, "install-host.command"));
57 chmodSync(join(dist, "install-host.command"), 0o755);
43 chmodSync(join(dist, "install-host.command"), 0o755);
scripts/install-manifest.sh+26−63
@@ -3,24 +3,19 @@
3# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
3# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
4# The release zip ships this same script as install-host.command next to host/ and extension-id,
4# The release zip ships this same script as install-host.command next to host/ and extension-id,
5# so `bash install-host.command` works from the unzipped extension folder too.
5# so `bash install-host.command` works from the unzipped extension folder too.
6−# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, registers the host
6+# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, writes the Brave
7−# (absolute path, pinned extension ID) with every Chromium browser on this Mac, then installs the fm schemas
7+# host manifest with that absolute path and the pinned extension ID, then installs the fm schemas
8# and runs the self-test. Safe to re-run.
8# and runs the self-test. Safe to re-run.
9set -euo pipefail
9set -euo pipefail
10−# Keep a copy of this run's output for diagnosis (brew's own output scrolls away).
11−LOG_DIR="$HOME/Library/Logs/Diagonal"
12−if mkdir -p "$LOG_DIR" 2>/dev/null; then
13− exec > >(tee "$LOG_DIR/install.log") 2>&1
14− echo "$(date '+%Y-%m-%d %H:%M:%S') install-host from $0 (HOME=$HOME PATH=$PATH)"
15−fi
16−trap 'echo "Diagonal host install failed at line $LINENO: $BASH_COMMAND" >&2' ERR
17HERE="$(cd "$(dirname "$0")" && pwd)"
10HERE="$(cd "$(dirname "$0")" && pwd)"
18if [[ -d "$HERE/host" ]]; then cd "$HERE"; else cd "$HERE/.."; fi
11if [[ -d "$HERE/host" ]]; then cd "$HERE"; else cd "$HERE/.."; fi
19
12
20−# (--beta, --nightly and --all-channels are accepted for old scripts; every browser is registered now.)
13+CHANNELS=("Brave-Browser")
21for arg in "$@"; do
14for arg in "$@"; do
22 case "$arg" in
15 case "$arg" in
23− --beta|--nightly|--all-channels) ;;
16+ --beta) CHANNELS+=("Brave-Browser-Beta") ;;
17+ --nightly) CHANNELS+=("Brave-Browser-Nightly") ;;
18+ --all-channels) CHANNELS=("Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly") ;;
24 *) echo "unknown option: $arg" >&2; exit 2 ;;
19 *) echo "unknown option: $arg" >&2; exit 2 ;;
25 esac
20 esac
26done
21done
@@ -50,57 +45,25 @@ xattr -dr com.apple.quarantine "$SHARE" 2>/dev/null || true
50ln -sf "$SHARE/diagonal-host.py" "$BIN"
45ln -sf "$SHARE/diagonal-host.py" "$BIN"
51echo "host: $BIN -> $SHARE/diagonal-host.py"
46echo "host: $BIN -> $SHARE/diagonal-host.py"
52
47
53−"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
48+for ch in "${CHANNELS[@]}"; do
54−# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
49+ DIR="$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts"
55−# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
50+ mkdir -p "$DIR"
56−# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
51+ "$BIN" --print-manifest "$BIN" > "$DIR/io.diagonal.host.json"
57−# says how to finish.
52+ echo "manifest: $DIR/io.diagonal.host.json"
58−register_rc=0
53+done
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
66−
67−FM="${DIAGONAL_FM:-/usr/bin/fm}"
68−
69−# fm refuses to run (exit 69) until someone accepts Apple's terms for it, once per Mac.
70−needs_license() {
71− local out rc=0
72− out=$("$FM" available --model system 2>&1) || rc=$?
73− [[ $rc -eq 69 ]] || grep -qiE "legal notice|fm license" <<<"$out"
74−}
75−
76−echo
77−if [[ ! -x "$FM" ]]; then
78− echo "Diagonal's host is installed, but $FM is missing: Diagonal needs macOS 27 with Apple Intelligence."
79− exit 0
80−fi
81−
82−if needs_license; then
83− echo "Diagonal names tabs with Apple's on-device model through the fm tool, which asks you to accept"
84− echo "Apple's terms once per Mac. The choice applies to every user here, so sudo asks for your password."
85− # Only with a terminal to read the terms and answer in; brew and double-click installs have one.
86− if [[ -z "${DIAGONAL_NO_PROMPT:-}" ]] && { : </dev/tty; } 2>/dev/null; then
87− ${DIAGONAL_SUDO-sudo} "$FM" license </dev/tty >/dev/tty 2>&1 || true
88− echo
89− fi
90− if needs_license; then
91− echo
92− echo "Diagonal is installed, but it can't name tabs until Apple's terms for fm are accepted."
93− echo "When you're ready, run: sudo fm license"
94− exit 0
95− fi
96−fi
97
54
98−# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
55+echo "schemas:"
56+rc=0
99−schemas_out=$("$BIN" --install-schemas 2>&1) || true
57+"$BIN" --install-schemas || rc=$?
100−if selftest_out=$("$BIN" --selftest 2>&1); then
58+if [[ $rc -eq 3 ]]; then
101− [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
59+ echo
102−else
60+ echo "One more step: accept Apple's terms for the fm tool once (asks for your password):"
103− echo "Diagonal's host is installed, but its self-test found a problem:"
61+ echo " sudo fm license"
104− echo "$schemas_out"
62+ echo "Diagonal finishes setting itself up the next time Brave talks to it."
105− echo "$selftest_out"
63+ echo
64+elif [[ $rc -ne 0 ]]; then
65+ echo " (schema install failed: is Apple Intelligence on? Details above.)"
106fi
66fi
67+echo "self-test:"
68+"$BIN" --selftest || true
69+echo "extension ID: $ID"
scripts/render-icons.mjsnew file+17
@@ -0,0 +1,17 @@
1+// Renders icons/icon.svg to the PNG sizes the manifest lists, using a local Chromium.
2+import { chromium } from "playwright-core";
3+import { readFileSync } from "node:fs";
4+import { dirname, join } from "node:path";
5+import { fileURLToPath } from "node:url";
6+
7+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
8+const svg = readFileSync(join(root, "icons/icon.svg"), "utf8");
9+const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome" });
10+for (const size of [16, 32, 48, 128]) {
11+ const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
12+ await page.setContent(`<html><body style="margin:0;background:transparent">${svg.replace("<svg ", `<svg width="${size}" height="${size}" `)}</body></html>`);
13+ await page.screenshot({ path: join(root, `icons/${size}.png`), omitBackground: true });
14+ await page.close();
15+}
16+await browser.close();
17+console.log("icons written");
scripts/render-icons.pydeleted−44
@@ -1,44 +0,0 @@
1−#!/usr/bin/env python3
2−"""Renders icons/icon.png to the PNG sizes the manifest lists. Needs Pillow (python3 -m pip install pillow).
3−
4−The source is the full-size app icon. Its transparent margin is trimmed first so the rounded square fills the
5−16 and 32 px toolbar icons; the larger sizes keep a little padding, as Chrome's extension page expects.
6−"""
7−from pathlib import Path
8−
9−from PIL import Image, ImageFilter
10−
11−ICONS = Path(__file__).resolve().parent.parent / "icons"
12−# size -> padding in px on each side at that size
13−SIZES = {16: 0, 32: 0, 48: 2, 128: 8}
14−
15−
16−def trimmed(src: Image.Image) -> Image.Image:
17− # Near-invisible specks around the shape would otherwise widen the bounding box.
18− solid = src.getchannel("A").point(lambda a: 255 if a > 32 else 0)
19− left, top, right, bottom = solid.getbbox()
20− side = max(right - left, bottom - top)
21− cx, cy = (left + right) / 2, (top + bottom) / 2
22− box = (round(cx - side / 2), round(cy - side / 2), round(cx + side / 2), round(cy + side / 2))
23− return src.crop(box)
24−
25−
26−def render(art: Image.Image, size: int, pad: int) -> Image.Image:
27− inner = size - 2 * pad
28− small = art.resize((inner, inner), Image.LANCZOS)
29− if inner <= 32:
30− small = small.filter(ImageFilter.UnsharpMask(radius=0.6, percent=60, threshold=0))
31− out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
32− out.paste(small, (pad, pad))
33− return out
34−
35−
36−def main() -> None:
37− art = trimmed(Image.open(ICONS / "icon.png").convert("RGBA"))
38− for size, pad in SIZES.items():
39− render(art, size, pad).save(ICONS / f"{size}.png", optimize=True)
40− print("icons written")
41−
42−
43−if __name__ == "__main__":
44− main()
scripts/store-assets.mjsdeleted−29
@@ -1,29 +0,0 @@
1−// Renders the Chrome Web Store images into docs/store/ from the popup and settings screenshots the smoke
2−// test takes: SMOKE_SHOTS=/tmp/shots npm run smoke && node scripts/store-assets.mjs /tmp/shots
3−import { chromium } from "playwright-core";
4−import { readFileSync } from "node:fs";
5−import { dirname, join } from "node:path";
6−import { fileURLToPath } from "node:url";
7−
8−const root = join(dirname(fileURLToPath(import.meta.url)), "..");
9−const shots = process.argv[2] || "/tmp/shots";
10−const img = (p) => `data:image/png;base64,${readFileSync(p).toString("base64")}`;
11−const icon = img(join(root, "icons/icon.png"));
12−const css = `body{margin:0;font:16px -apple-system,system-ui,sans-serif;background:linear-gradient(135deg,#0d3b2e,#1f6f55);color:#fff;display:flex;align-items:center;justify-content:center;gap:56px;height:100vh;overflow:hidden}
13−h1{font-size:40px;margin:0 0 16px;line-height:1.15} p{font-size:20px;line-height:1.45;margin:0;opacity:.9;max-width:460px}
14−.shot{border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.35);background:#fff}`;
15−
16−const pages = [
17− { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
18− { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac and sees only a tab's title, address and description. Nothing leaves the Mac.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
19− { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
20−];
21−
22−const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome" });
23−for (const p of pages) {
24− const page = await browser.newPage({ viewport: { width: p.w, height: p.h } });
25− await page.setContent(`<style>${css}${p.gap ? `body{gap:${p.gap}px}` : ""}</style>${p.html}`);
26− await page.screenshot({ path: join(root, "docs/store", p.file) });
27− console.log("wrote docs/store/" + p.file);
28−}
29−await browser.close();
src/background/engine.ts+6−137
@@ -1,9 +1,8 @@
1import type { GroupColor } from "../shared/colors";
1import type { GroupColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
2import { isColor } from "../shared/colors";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
3import { colorFor, isInternalUrl, parse, provisionalTitle, trimText } from "../shared/url";
4−import { titleKey } from "../shared/label";
5import type { Settings } from "./settings";
4import type { Settings } from "./settings";
6−import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type RemovedGroup, type State, type TabRecord } from "./state";
5+import { markDirty, newGroupRecord, type GroupOrigin, type GroupRecord, type State, type TabRecord } from "./state";
7
6
8/**
7/**
9 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
8 * Section 5: the structural rules as a pure function `(State, Event) → (State, Action[])`.
@@ -51,8 +50,7 @@ export type Action =
51 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
50 | { type: "scheduleDissolve"; groupId: number; delayMs: number }
52 | { type: "dirty"; groupId: number }
51 | { type: "dirty"; groupId: number }
53 | { type: "loose"; windowId: number }
52 | { type: "loose"; windowId: number }
54− | { type: "unpark"; tabId: number; groupId: number }
53+ | { type: "unpark"; tabId: number; groupId: number };
55− | { type: "checkFit" };
56
54
57export interface Ctx {
55export interface Ctx {
58 now: number;
56 now: number;
@@ -62,10 +60,6 @@ export interface Ctx {
62export const DISSOLVE_DELAY_MS = 1500;
60export const DISSOLVE_DELAY_MS = 1500;
63export const OWN_CREATE_WINDOW_MS = 2000;
61export const OWN_CREATE_WINDOW_MS = 2000;
64export const OWN_WRITE_WINDOW_MS = 60_000;
62export 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;
69
63
70/** The pure form: never touches `prev`. */
64/** The pure form: never touches `prev`. */
71export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
65export function step(prev: State, event: EngineEvent, ctx: Ctx): { state: State; actions: Action[] } {
@@ -90,16 +84,8 @@ type Handlers = { [K in EngineEvent["type"]]: Handler<Extract<EngineEvent, { typ
90const handlers: Handlers = {
84const handlers: Handlers = {
91 tabCreated(s, { tab, opener }, ctx, out) {
85 tabCreated(s, { tab, opener }, ctx, out) {
92 if (tab.incognito) return;
86 if (tab.incognito) return;
93− const known = !!s.tabs[tab.id];
94 const rec = upsertTab(s, tab, ctx.now);
87 const rec = upsertTab(s, tab, ctx.now);
95− if (rec.groupId !== -1) {
96− // Born inside a group with no page that led to it ("New tab in group"): the user put it there.
97− // A link opened from a grouped tab has an opener and stays Diagonal's to check.
98− const g = s.groups[rec.groupId];
99− const newPage = tab.openerTabId === undefined || isInternalUrl(tab.pendingUrl || tab.url);
100− if (!known && newPage && g && !revivedLately(g, ctx.now)) rec.handPlaced = true;
101− dirty(s, rec.groupId, ctx, out);
88+ if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
102− }
103 const before = out.length;
89 const before = out.length;
104 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
90 openerRule(s, tab, opener ?? snapshotOf(s, tab.openerTabId), ctx, out);
105 if (out.length === before) loose(rec, ctx, out);
91 if (out.length === before) loose(rec, ctx, out);
@@ -114,33 +100,11 @@ const handlers: Handlers = {
114 else loose(rec, ctx, out);
100 else loose(rec, ctx, out);
115 return;
101 return;
116 }
102 }
117− const titleChanged = titleKey(before.title) !== titleKey(rec.title); // not an unread count ticking over
103+ const titleChanged = before.title !== rec.title;
118 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
104 const pathChanged = pathKey(before.url) !== pathKey(rec.url);
119 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
105 if (pathChanged) rec.keepLoose = undefined; // a new page is fair game again
120 if (before.groupId !== rec.groupId) {
106 if (before.groupId !== rec.groupId) {
121 left(s, before.groupId, ctx, out);
107 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− }
144 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
108 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
145 else if (before.groupId !== -1) {
109 else if (before.groupId !== -1) {
146 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
110 // Out of a group: ours if the worker dissolved it, otherwise the user's choice to keep it loose.
@@ -158,10 +122,6 @@ const handlers: Handlers = {
158 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
122 if (titleChanged || pathChanged || loaded || before.pinned !== rec.pinned) loose(rec, ctx, out);
159 return;
123 return;
160 }
124 }
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" });
165 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
125 if (titleChanged || pathChanged) dirty(s, rec.groupId, ctx, out);
166 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
126 else if (before.status !== "complete" && rec.status === "complete" && s.groups[rec.groupId]?.dirty) {
167 // A member finished loading: the naming loop may have been waiting for it.
127 // A member finished loading: the naming loop may have been waiting for it.
@@ -180,12 +140,9 @@ const handlers: Handlers = {
180 const rec = s.tabs[tabId];
140 const rec = s.tabs[tabId];
181 if (!rec) return;
141 if (!rec) return;
182 rec.lastActivatedAt = ctx.now;
142 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" });
185 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
143 // Using a parked tab means it is not stale: it leaves Parked and auto-organize places it.
186 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
144 if (rec.groupId !== -1 && s.groups[rec.groupId]?.origin === "tidy") {
187 rec.parkedFrom = undefined;
145 rec.parkedFrom = undefined;
188− rec.parkedAt = undefined;
189 out.push({ type: "unpark", tabId, groupId: rec.groupId });
146 out.push({ type: "unpark", tabId, groupId: rec.groupId });
190 }
147 }
191 },
148 },
@@ -200,15 +157,6 @@ const handlers: Handlers = {
200 registerGroup(s, group, ctx, out);
157 registerGroup(s, group, ctx, out);
201 return;
158 return;
202 }
159 }
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− }
212 rec.windowId = group.windowId;
160 rec.windowId = group.windowId;
213 const own = s.ownWrites[group.id];
161 const own = s.ownWrites[group.id];
214 const title = group.title ?? "";
162 const title = group.title ?? "";
@@ -236,17 +184,15 @@ const handlers: Handlers = {
236 }
184 }
237 },
185 },
238
186
239− groupRemoved(s, { groupId }, ctx) {
187+ groupRemoved(s, { groupId }) {
240− const rec = s.groups[groupId];
241 delete s.groups[groupId];
188 delete s.groups[groupId];
242 delete s.ownWrites[groupId];
189 delete s.ownWrites[groupId];
243− if (rec) bury(s, rec, ctx.now);
244 },
190 },
245
191
246 meta(s, { tabId, description }, ctx, out) {
192 meta(s, { tabId, description }, ctx, out) {
247 const rec = s.tabs[tabId];
193 const rec = s.tabs[tabId];
248 if (!rec) return;
194 if (!rec) return;
249− const d = trimText(description, 500) || undefined;
195+ const d = trimText(description, 300) || undefined;
250 if (d === rec.description) return;
196 if (d === rec.description) return;
251 rec.description = d;
197 rec.description = d;
252 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
198 if (rec.groupId !== -1) dirty(s, rec.groupId, ctx, out);
@@ -290,21 +236,6 @@ function openerRule(s: State, child: TabSnapshot, opener: TabSnapshot | undefine
290export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
236export const shouldDissolve = (g: GroupRecord, settings: Settings): boolean =>
291 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
237 g.managed && (g.origin === "opener" || g.origin === "organize") && !g.userNamed && settings.dissolveSingletons;
292
238
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−
308/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
239/** An ungrouped tab changed: auto-organize should look at its window once things settle. */
309function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
240function loose(rec: TabRecord, ctx: Ctx, out: Action[]): void {
310 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
241 if (!ctx.settings.autoOrganize || rec.groupId !== -1 || rec.pinned || rec.keepLoose || isInternalUrl(rec.url)) return;
@@ -343,64 +274,16 @@ function registerGroup(s: State, group: GroupSnapshot, ctx: Ctx, out: Action[]):
343 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
274 s.groups[group.id] = newGroupRecord(group.id, group.windowId, pending.origin, color, { stripTitle: group.title ?? "" });
344 return;
275 return;
345 }
276 }
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− }
353 const titled = !!group.title;
277 const titled = !!group.title;
354 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
278 const rec = newGroupRecord(group.id, group.windowId, "user", color, {
355 managed: ctx.settings.nameUserGroups,
279 managed: ctx.settings.nameUserGroups,
356 userNamed: titled,
280 userNamed: titled,
357 stripTitle: group.title ?? "",
281 stripTitle: group.title ?? "",
358− registeredAt: ctx.now,
359 });
282 });
360 s.groups[group.id] = rec;
283 s.groups[group.id] = rec;
361 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
284 if (rec.managed && !titled) dirty(s, group.id, ctx, out);
362}
285}
363
286
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−
404function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
287function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
405 const before = s.tabs[tab.id];
288 const before = s.tabs[tab.id];
406 const url = tab.url || tab.pendingUrl || "";
289 const url = tab.url || tab.pendingUrl || "";
@@ -421,12 +304,7 @@ function upsertTab(s: State, tab: TabSnapshot, now: number): TabRecord {
421 // A description belongs to a page: keep it only while the tab stays on that page.
304 // A description belongs to a page: keep it only while the tab stays on that page.
422 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
305 if (before?.description && pathKey(before.url) === pathKey(url)) rec.description = before.description;
423 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
306 if (before?.keepLoose) rec.keepLoose = before.keepLoose;
424− if (before?.parkedFrom) rec.parkedFrom = before.parkedFrom;
425− if (before?.parkedAt) rec.parkedAt = before.parkedAt;
426 if (before?.organizedKey) rec.organizedKey = before.organizedKey;
307 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;
430 s.tabs[tab.id] = rec;
308 s.tabs[tab.id] = rec;
431 return rec;
309 return rec;
432}
310}
@@ -449,13 +327,4 @@ function prune(s: State, now: number): void {
449 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
327 s.pendingCreates = s.pendingCreates.filter((p) => now - p.at <= OWN_CREATE_WINDOW_MS);
450 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
328 for (const [id, w] of Object.entries(s.ownWrites)) if (now - w.at > OWN_WRITE_WINDOW_MS) delete s.ownWrites[+id];
451 for (const [id, at] of Object.entries(s.ownUngroups)) if (now - at > OWN_WRITE_WINDOW_MS) delete s.ownUngroups[+id];
329 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− });
461}
330}
src/background/fit.tsdeleted−122
@@ -1,122 +0,0 @@
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+4−8
@@ -2,14 +2,13 @@ import type { HostError, HostErrorCode } from "./state";
2
2
3/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
3/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
4export const HOST_NAME = "io.diagonal.host";
4export const HOST_NAME = "io.diagonal.host";
5−/** Each Chromium browser reads its own folder, e.g. Google/Chrome, BraveSoftware/Brave-Origin, Microsoft Edge. */
6−export const HOST_MANIFEST_DIR = "~/Library/Application Support/<browser>/NativeMessagingHosts/";
5+export const HOST_MANIFEST_DIR = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/";
7export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
6export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
8
7
9export type Op = "ping" | "name" | "organize";
8export type Op = "ping" | "name" | "organize";
10
9
11export interface HostOpts {
10export interface HostOpts {
12− model: "system";
11+ model: "system" | "pcc";
13 timeoutMs: number;
12 timeoutMs: number;
14 emoji: boolean;
13 emoji: boolean;
15 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
14 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
@@ -102,9 +101,6 @@ export function normalizeReply<T>(reply: unknown, at: number): HostReply<T> {
102 };
101 };
103}
102}
104
103
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−
108/** Errors that mean "fix the setup", not "try this group again later". */
104/** Errors that mean "fix the setup", not "try this group again later". */
109export const SETUP_ERRORS = new Set<HostErrorCode>([
105export const SETUP_ERRORS = new Set<HostErrorCode>([
110 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
106 "HOST_NOT_FOUND", "HOST_FORBIDDEN", "FORBIDDEN_ORIGIN", "SCHEMA_MISSING", "MODEL_UNAVAILABLE", "LICENSE_REQUIRED",
@@ -115,14 +111,14 @@ export function explain(error: HostError | undefined, extensionId = ""): string
115 if (!error) return "";
111 if (!error) return "";
116 switch (error.code) {
112 switch (error.code) {
117 case "HOST_NOT_FOUND":
113 case "HOST_NOT_FOUND":
118− return `Native host not installed for this browser (no manifest at ${HOST_MANIFEST_PATH}). Installed with Homebrew? Run: brew reinstall diagonal. Otherwise run: bash <the Diagonal folder>/install-host.command. Either one registers the host with every Chromium browser on this Mac.`;
114+ return `Native host not installed. In Terminal, run: bash <the Diagonal folder>/install-host.command (expected manifest at ${HOST_MANIFEST_PATH})`;
119 case "HOST_FORBIDDEN":
115 case "HOST_FORBIDDEN":
120 case "FORBIDDEN_ORIGIN":
116 case "FORBIDDEN_ORIGIN":
121 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
117 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
122 case "LICENSE_REQUIRED":
118 case "LICENSE_REQUIRED":
123 return "Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: sudo fm license. Diagonal picks up from there on its own.";
119 return "Apple's fm tool needs its terms accepted once on this Mac. In Terminal, run: sudo fm license. Diagonal picks up from there on its own.";
124 case "SCHEMA_MISSING":
120 case "SCHEMA_MISSING":
125− return `${error.message}. Diagonal retries on its own; to see the full reason, run in Terminal: ~/.local/bin/diagonal-host --selftest`;
121+ return "Schemas missing. Run: diagonal-host --install-schemas";
126 case "MODEL_UNAVAILABLE":
122 case "MODEL_UNAVAILABLE":
127 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
123 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
128 case "RATE_LIMITED":
124 case "RATE_LIMITED":
src/background/index.ts+72−195
@@ -1,27 +1,20 @@
1import { stripTitle } from "../shared/label";
1import { stripTitle } from "../shared/label";
2−import { applyEvent, bury, type Action, type EngineEvent, type GroupSnapshot, type TabSnapshot } from "./engine";
2+import { applyEvent, 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";
3+import { callHost, chromeSender, explain, HOST_MANIFEST_PATH, SETUP_ERRORS, type HostOpts, type HostReply, type Op } from "./host";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
4import { GLOBAL_PAUSE_AFTER, GLOBAL_PAUSE_MS, Naming, RATE_LIMIT_PAUSES_MS, type Member, type NamePayload, type NameResult } from "./naming";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
5import { AutoOrganizer, organizeWindow, undoOrganize, UNDO_WINDOW_MS as ORGANIZE_UNDO_MS } from "./organize";
6−import { FitChecker } from "./fit";
7import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, ungroup, updateGroup, type Runtime } from "./runtime";
8import { withDefaults, type Settings } from "./settings";
7import { withDefaults, type Settings } from "./settings";
9−import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type RemovedGroup, type State, type TabRecord } from "./state";
8+import { markDirty, migrate, newGroupRecord, type GroupRecord, type HostError, type PingResult, type State, type TabRecord } from "./state";
10−import { readDisk, shouldReload, TRIED_KEY, UPDATE_CHECK_MINUTES } from "./selfupdate";
11−import { adoptParked, forgetArchived, looksParked, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
9+import { forgetArchived, parkedTitle, restoreAll, restoreArchived, runSweep, undoSweep, UNDO_WINDOW_MS as SWEEP_UNDO_MS } from "./tidy";
12
10
13/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
11/** Event wiring only: every rule lives in engine / naming / organize / tidy. */
14
12
15−const GROUPS_SUPPORTED = typeof chrome.tabGroups?.query === "function";
16−
17const ALARM_NAMING = "naming-fallback";
13const ALARM_NAMING = "naming-fallback";
18const ALARM_TIDY = "tidy-sweep";
14const ALARM_TIDY = "tidy-sweep";
19const ALARM_HOST = "host-retry";
15const ALARM_HOST = "host-retry";
20const ALARM_AUTO = "auto-organize";
16const ALARM_AUTO = "auto-organize";
21−const ALARM_UPDATE = "self-update";
22const HOST_RETRY_MS = 600_000;
17const 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();
25
18
26// ----- state cache ------------------------------------------------------------------------------
19// ----- state cache ------------------------------------------------------------------------------
27
20
@@ -93,11 +86,7 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
93 h.lastOkAt = Date.now();
86 h.lastOkAt = Date.now();
94 h.consecutiveFailures = 0;
87 h.consecutiveFailures = 0;
95 h.consecutiveTimeouts = 0;
88 h.consecutiveTimeouts = 0;
96− if (op !== "ping") {
89+ h.rateLimitStep = 0;
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− }
101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
90 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
102 } else {
91 } else {
103 recordHostError(reply.error);
92 recordHostError(reply.error);
@@ -111,8 +100,6 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
111}
100}
112
101
113function recordHostError(e: HostError): void {
102function 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;
116 const h = state.host;
103 const h = state.host;
117 const now = Date.now();
104 const now = Date.now();
118 h.lastError = { ...e, at: now };
105 h.lastError = { ...e, at: now };
@@ -142,9 +129,13 @@ async function ping(): Promise<HostReply<PingResult>> {
142 } else if (!reply.result.fmAvailable) {
129 } else if (!reply.result.fmAvailable) {
143 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
130 recordHostError({ code: "MODEL_UNAVAILABLE", message: reply.result.fmMessage || "fm reports the model unavailable" });
144 } else if (!reply.result.schemasOk) {
131 } else if (!reply.result.schemasOk) {
145− recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
132+ recordHostError({ code: "SCHEMA_MISSING", message: "schema files missing" });
146 } else {
133 } else {
147− recovered();
134+ h.pausedUntil = undefined;
135+ h.lastError = undefined;
136+ chrome.alarms.clear(ALARM_HOST);
137+ void naming.sweepDirty();
138+ void autoOrganizer.sweep();
148 }
139 }
149 }
140 }
150 commit();
141 commit();
@@ -152,16 +143,6 @@ async function ping(): Promise<HostReply<PingResult>> {
152 return reply;
143 return reply;
153}
144}
154
145
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−
165// ----- badge -----------------------------------------------------------------------------------
146// ----- badge -----------------------------------------------------------------------------------
166
147
167function busy(on: boolean): void {
148function busy(on: boolean): void {
@@ -173,6 +154,7 @@ function hostUnhealthy(): boolean {
173 const h = state.host;
154 const h = state.host;
174 const now = Date.now();
155 const now = Date.now();
175 if (h.pausedUntil && h.pausedUntil > now) return true;
156 if (h.pausedUntil && h.pausedUntil > now) return true;
157+ if (h.consecutiveFailures >= 3) return true;
176 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
158 return !!h.lastError && SETUP_ERRORS.has(h.lastError.code) && (!h.lastOkAt || h.lastOkAt < (h.lastError.at ?? 0));
177}
159}
178
160
@@ -253,7 +235,6 @@ async function scheduleAutoFallback(when: number): Promise<void> {
253}
235}
254
236
255const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
237const autoOrganizer = new AutoOrganizer(rt, { scheduleFallback: (when) => void scheduleAutoFallback(when) });
256−const fitChecker = new FitChecker(rt);
257
238
258// ----- engine glue -----------------------------------------------------------------------------
239// ----- engine glue -----------------------------------------------------------------------------
259
240
@@ -342,9 +323,6 @@ async function run(a: Action): Promise<void> {
342 case "dirty":
323 case "dirty":
343 naming.touch(a.groupId);
324 naming.touch(a.groupId);
344 break;
325 break;
345− case "checkFit":
346− fitChecker.touch();
347− break;
348 }
326 }
349}
327}
350
328
@@ -357,26 +335,18 @@ async function tabById(id: number): Promise<chrome.tabs.Tab | undefined> {
357/**
335/**
358 * On startup and install: rebuild the state model from the live browser. Tab and group ids change
336 * On startup and install: rebuild the state model from the live browser. Tab and group ids change
359 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
337 * across restarts, so records are matched by id first and then by URL (tabs) or title and colour
360− * (groups), which keeps managed groups managed after the browser restarts.
338+ * (groups), which keeps managed groups managed after Brave restarts.
361 */
339 */
362−async function reconcile(reason: "startup" | "installed"): Promise<void> {
340+async function reconcile(): Promise<void> {
363 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
341 const [tabs, groups] = await Promise.all([chrome.tabs.query({}), chrome.tabGroups.query({})]);
364 const now = Date.now();
342 const now = Date.now();
365− const liveTabIds = new Set(tabs.map((t) => t.id));
343+ const oldTabs = Object.values(state.tabs);
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]));
344+ const byUrl = new Map<string, TabRecord>(oldTabs.map((t) => [t.url, t]));
368 const nextTabs: Record<number, TabRecord> = {};
345 const nextTabs: Record<number, TabRecord> = {};
369 for (const t of tabs) {
346 for (const t of tabs) {
370 if (t.incognito || t.id === undefined) continue;
347 if (t.incognito || t.id === undefined) continue;
371 const url = t.url || t.pendingUrl || "";
348 const url = t.url || t.pendingUrl || "";
372− const same = state.tabs[t.id]?.url === url ? state.tabs[t.id] : undefined;
349+ const prev = state.tabs[t.id]?.url === url ? state.tabs[t.id] : byUrl.get(url);
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);
380 nextTabs[t.id] = {
350 nextTabs[t.id] = {
381 id: t.id,
351 id: t.id,
382 windowId: t.windowId,
352 windowId: t.windowId,
@@ -389,51 +359,24 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
389 openerTabId: t.openerTabId,
359 openerTabId: t.openerTabId,
390 description: prev?.description,
360 description: prev?.description,
391 parkedFrom: prev?.parkedFrom,
361 parkedFrom: prev?.parkedFrom,
392− parkedAt: prev?.parkedAt,
393 keepLoose: prev?.keepLoose,
362 keepLoose: prev?.keepLoose,
394 organizedKey: prev?.organizedKey,
363 organizedKey: prev?.organizedKey,
395− organizeRefused: prev?.organizeRefused,
396− handPlaced: prev?.handPlaced,
397− fitPending: prev?.fitPending,
398 createdAt: prev?.createdAt ?? now,
364 createdAt: prev?.createdAt ?? now,
399− lastActivatedAt: lastSeen || now,
365+ lastActivatedAt: Math.max(prev?.lastActivatedAt ?? 0, t.lastAccessed ?? 0) || now,
400 };
366 };
401 }
367 }
402− const liveGroupIds = new Set(groups.map((g) => g.id));
403 const unmatched = new Map(Object.values(state.groups).map((g) => [g.id, g]));
368 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− };
417 const nextGroups: Record<number, GroupRecord> = {};
369 const nextGroups: Record<number, GroupRecord> = {};
418 for (const g of groups) {
370 for (const g of groups) {
419 const title = g.title ?? "";
371 const title = g.title ?? "";
420− let prev: GroupRecord | undefined = unmatched.get(g.id);
372+ let prev = unmatched.get(g.id);
421− if (prev && (prev.stripTitle ?? "") !== title) prev = undefined;
373+ if (!prev || (prev.stripTitle ?? "") !== title) {
422− const before = !prev || fresh(prev) ? lookalike(title, g.color) : undefined;
374+ prev = [...unmatched.values()].find((o) => (o.stripTitle ?? "") === title && title !== "" && o.color === g.color);
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 });
434 }
375 }
435− // Earlier versions turned Parked into a plain group after a restart: give it back its job.
376+ if (prev) unmatched.delete(prev.id);
436− if (rec.origin === "user" && looksParked(g, settings)) adoptParked(rec, settings);
377+ const rec: GroupRecord = prev
378+ ? { ...prev, id: g.id, windowId: g.windowId, color: g.color, stripTitle: title }
379+ : newGroupRecord(g.id, g.windowId, "user", g.color, { managed: settings.nameUserGroups, userNamed: !!title, stripTitle: title });
437 if (rec.managed) {
380 if (rec.managed) {
438 rec.dirty = true;
381 rec.dirty = true;
439 rec.dirtyAt = 0;
382 rec.dirtyAt = 0;
@@ -441,17 +384,13 @@ async function reconcile(reason: "startup" | "installed"): Promise<void> {
441 }
384 }
442 nextGroups[g.id] = rec;
385 nextGroups[g.id] = rec;
443 }
386 }
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);
446 state.tabs = nextTabs;
387 state.tabs = nextTabs;
447 state.groups = nextGroups;
388 state.groups = nextGroups;
448 state.pendingCreates = [];
389 state.pendingCreates = [];
449 state.ownWrites = {};
390 state.ownWrites = {};
450 state.ownUngroups = {};
391 state.ownUngroups = {};
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;
392+ if (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;
393+ if (state.lastOrganize && now - state.lastOrganize.at > ORGANIZE_UNDO_MS) state.lastOrganize = undefined;
455 commit();
394 commit();
456}
395}
457
396
@@ -464,13 +403,9 @@ async function injectBacklog(): Promise<void> {
464 }
403 }
465}
404}
466
405
467−let reconciled: Promise<void> | undefined;
468−
469async function boot(reason: "startup" | "installed"): Promise<void> {
406async function boot(reason: "startup" | "installed"): Promise<void> {
470− // Queued before anything else this wake handles (serial waits for `ready` itself): after a restart
407+ await ready;
471− // Chromium replays the restored groups right after onStartup, and they must meet the reconciled state.
472− reconciled = serial("reconcile", () => reconcile(reason));
408+ await serial("reconcile", reconcile);
473− await reconciled;
474 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
409 if (!(await chrome.alarms.get(ALARM_TIDY))) chrome.alarms.create(ALARM_TIDY, { periodInMinutes: 30, delayInMinutes: 1 });
475 if (reason === "installed") void injectBacklog();
410 if (reason === "installed") void injectBacklog();
476 await ping();
411 await ping();
@@ -482,91 +417,55 @@ async function boot(reason: "startup" | "installed"): Promise<void> {
482
417
483// ----- browser events --------------------------------------------------------------------------
418// ----- browser events --------------------------------------------------------------------------
484
419
485−// Every Chromium browser has tabs, but not all let extensions manage tab groups (chrome.tabGroups,
486−// Chromium 89+): without it Diagonal stays idle and the popup says why.
487−if (GROUPS_SUPPORTED) {
488− chrome.runtime.onStartup.addListener(() => void boot("startup"));
420+chrome.runtime.onStartup.addListener(() => void boot("startup"));
489− chrome.runtime.onInstalled.addListener(() => void boot("installed"));
421+chrome.runtime.onInstalled.addListener(() => void boot("installed"));
490−
491− chrome.tabs.onCreated.addListener((tab) =>
492− serial("tabs.onCreated", async () => {
493− const opener = tab.openerTabId !== undefined ? await tabById(tab.openerTabId) : undefined;
494− await feed({ type: "tabCreated", tab: snapTab(tab), opener: opener && snapTab(opener) });
495− }),
496− );
497−
498− chrome.tabs.onUpdated.addListener((_id, change, tab) => {
499− if (!("title" in change || "url" in change || "status" in change || "groupId" in change || "pinned" in change)) return;
500− void serial("tabs.onUpdated", () => feed({ type: "tabUpdated", tab: snapTab(tab) }));
501− });
502
422
503− const refetch = (where: string) => (tabId: number) =>
423+chrome.tabs.onCreated.addListener((tab) =>
504− void serial(where, async () => {
424+ serial("tabs.onCreated", async () => {
505− const t = await tabById(tabId);
425+ const opener = tab.openerTabId !== undefined ? await tabById(tab.openerTabId) : undefined;
506− if (t) await feed({ type: "tabUpdated", tab: snapTab(t) });
426+ await feed({ type: "tabCreated", tab: snapTab(tab), opener: opener && snapTab(opener) });
507− });
508− chrome.tabs.onMoved.addListener(refetch("tabs.onMoved"));
509− chrome.tabs.onAttached.addListener(refetch("tabs.onAttached"));
510−
511− chrome.tabs.onRemoved.addListener((tabId) => void serial("tabs.onRemoved", () => feed({ type: "tabRemoved", tabId })));
512−
513− chrome.tabs.onReplaced.addListener((added, removed) =>
514− 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.
517− const rec = state.tabs[removed];
518− delete state.tabs[removed];
519− const t = await tabById(added);
520− if (t) {
521− if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
522− await feed({ type: "tabUpdated", tab: snapTab(t) });
523− }
524− }),
427+ }),
525− );
428+);
526
429
527− chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
430+chrome.tabs.onUpdated.addListener((_id, change, tab) => {
431+ if (!("title" in change || "url" in change || "status" in change || "groupId" in change || "pinned" in change)) return;
432+ void serial("tabs.onUpdated", () => feed({ type: "tabUpdated", tab: snapTab(tab) }));
433+});
528
434
529− // A group is read when its turn comes, not as Chromium sent it: a restored group reports itself
435+const refetch = (where: string) => (tabId: number) =>
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 () => {
436+ void serial(where, async () => {
533− const now = await chrome.tabGroups.get(g.id).catch(() => undefined);
437+ const t = await tabById(tabId);
534− if (now) await feed({ type, group: snapGroup(now) });
438+ if (t) await feed({ type: "tabUpdated", tab: snapTab(t) });
535− });
439+ });
536− chrome.tabGroups.onCreated.addListener(refetchGroup("tabGroups.onCreated", "groupCreated"));
440+chrome.tabs.onMoved.addListener(refetch("tabs.onMoved"));
537− chrome.tabGroups.onUpdated.addListener(refetchGroup("tabGroups.onUpdated", "groupUpdated"));
441+chrome.tabs.onAttached.addListener(refetch("tabs.onAttached"));
538− chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
442+
443+chrome.tabs.onRemoved.addListener((tabId) => void serial("tabs.onRemoved", () => feed({ type: "tabRemoved", tabId })));
444+
445+chrome.tabs.onReplaced.addListener((added, removed) =>
446+ void serial("tabs.onReplaced", async () => {
447+ const rec = state.tabs[removed];
448+ delete state.tabs[removed];
449+ const t = await tabById(added);
450+ if (t) {
451+ await feed({ type: "tabUpdated", tab: snapTab(t) });
452+ if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
453+ }
539−}
454+ }),
455+);
540
456
541−// ----- self-update -------------------------------------------------------------------------------
457+chrome.tabs.onActivated.addListener(({ tabId }) => void serial("tabs.onActivated", () => feed({ type: "tabActivated", tabId })));
542−
543−/** After `brew upgrade`, reload into the new files on disk so nobody has to click reload. */
544−async function checkForUpdate(): Promise<void> {
545− const self = await chrome.management.getSelf().catch(() => undefined);
546− if (self?.installType !== "development") return; // store installs update themselves
547− if (busyCount > 0) return; // a model call is in flight; the next check picks it up
548− const disk = await readDisk(fetch, (p) => chrome.runtime.getURL(p));
549− const lastTried = (await chrome.storage.local.get(TRIED_KEY))[TRIED_KEY] as string | undefined;
550− if (!shouldReload({ running: chrome.runtime.getManifest().version, lastTried, ...disk })) return;
551− await chrome.storage.local.set({ [TRIED_KEY]: disk.onDisk });
552− console.info(`[diagonal] reloading into ${disk.onDisk} from disk`);
553− chrome.runtime.reload();
554−}
555
458
556−void (async () => {
459+chrome.tabGroups.onCreated.addListener((g) => void serial("tabGroups.onCreated", () => feed({ type: "groupCreated", group: snapGroup(g) })));
557− if (!(await chrome.alarms.get(ALARM_UPDATE))) chrome.alarms.create(ALARM_UPDATE, { periodInMinutes: UPDATE_CHECK_MINUTES, delayInMinutes: 1 });
460+chrome.tabGroups.onUpdated.addListener((g) => void serial("tabGroups.onUpdated", () => feed({ type: "groupUpdated", group: snapGroup(g) })));
558−})();
461+chrome.tabGroups.onRemoved.addListener((g) => void serial("tabGroups.onRemoved", () => feed({ type: "groupRemoved", groupId: g.id })));
559
462
560chrome.alarms.onAlarm.addListener((alarm) => {
463chrome.alarms.onAlarm.addListener((alarm) => {
561 void (async () => {
464 void (async () => {
562− if (alarm.name === ALARM_UPDATE) return void checkForUpdate().catch((e) => logError("self-update", e));
563 await ready;
465 await ready;
564 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
466 if (alarm.name === ALARM_NAMING) await naming.sweepDirty();
565 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
467 else if (alarm.name === ALARM_AUTO) await autoOrganizer.sweep();
566− else if (alarm.name === ALARM_TIDY) {
468+ else if (alarm.name === ALARM_TIDY) await runSweep(rt).catch((e) => logError("tidy", e));
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− }
570 else if (alarm.name === ALARM_HOST) {
469 else if (alarm.name === ALARM_HOST) {
571 const r = await ping();
470 const r = await ping();
572 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
471 if (!r.ok || hostUnhealthy()) chrome.alarms.create(ALARM_HOST, { when: Date.now() + HOST_RETRY_MS });
@@ -580,46 +479,27 @@ chrome.storage.onChanged.addListener((changes, area) => {
580 await ready;
479 await ready;
581 const before = settings;
480 const before = settings;
582 settings = withDefaults(changes.settings.newValue);
481 settings = withDefaults(changes.settings.newValue);
583− if (before.emoji !== settings.emoji) await rewriteTitles(before);
482+ if (before.emoji !== settings.emoji) await rewriteTitles();
584− if (before.nameUserGroups !== settings.nameUserGroups) applyNameUserGroups();
585 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
483 if (before.tidyMode !== settings.tidyMode || before.tidyThreshold !== settings.tidyThreshold) await runSweep(rt).catch(() => undefined);
586 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
484 if (!before.autoOrganize && settings.autoOrganize) await autoOrganizer.sweep();
587 refreshBadge();
485 refreshBadge();
588 })();
486 })();
589});
487});
590
488
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−
604/** Emoji switched on or off: rewrite the titles the extension wrote. */
489/** Emoji switched on or off: rewrite the titles the extension wrote. */
605−async function rewriteTitles(before: Settings): Promise<void> {
490+async function rewriteTitles(): Promise<void> {
606 for (const g of Object.values(state.groups)) {
491 for (const g of Object.values(state.groups)) {
607− // Parked is always "user named", so its title is compared instead: one you typed stays.
492+ if (g.origin === "tidy") await updateGroup(rt, g.id, { title: parkedTitle(settings) });
608− if (g.origin === "tidy") {
493+ else if (g.managed && !g.userNamed && g.title) await updateGroup(rt, g.id, { title: stripTitle(g.title, g.emoji, settings.emoji) });
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) });
611 }
494 }
612}
495}
613
496
614async function nameGroupNow(groupId: number): Promise<void> {
497async function nameGroupNow(groupId: number): Promise<void> {
615 const g = state.groups[groupId];
498 const g = state.groups[groupId];
616 if (!g) return;
499 if (!g) return;
617− // An explicit request overrides "don't name" and a hand-typed title for this group, and the model's
500+ // An explicit request overrides "don't name" and a hand-typed title for this group.
618− // answer goes on the strip even when it is close to the label it gave before.
619 g.managed = true;
501 g.managed = true;
620 g.userNamed = false;
502 g.userNamed = false;
621− g.title = undefined;
622− g.lastNamedAt = undefined;
623 commit();
503 commit();
624 await naming.nameNow(groupId);
504 await naming.nameNow(groupId);
625}
505}
@@ -651,7 +531,6 @@ chrome.runtime.onMessage.addListener((msg: Msg, sender, sendResponse) => {
651async function handleCommand(msg: Msg): Promise<unknown> {
531async function handleCommand(msg: Msg): Promise<unknown> {
652 switch (msg.cmd) {
532 switch (msg.cmd) {
653 case "status":
533 case "status":
654− void checkForUpdate().catch((e) => logError("self-update", e)); // opening the popup checks too
655 return statusFor(msg.windowId);
534 return statusFor(msg.windowId);
656 case "ping":
535 case "ping":
657 return ping();
536 return ping();
@@ -750,7 +629,6 @@ async function statusFor(windowId: number | undefined): Promise<unknown> {
750 .map(async (g) => ({
629 .map(async (g) => ({
751 id: g.id,
630 id: g.id,
752 title: g.stripTitle || g.title || "",
631 title: g.stripTitle || g.title || "",
753− color: g.color,
754 origin: g.origin,
632 origin: g.origin,
755 managed: g.managed,
633 managed: g.managed,
756 userNamed: g.userNamed,
634 userNamed: g.userNamed,
@@ -759,7 +637,6 @@ async function statusFor(windowId: number | undefined): Promise<unknown> {
759 size: (await chrome.tabs.query({ groupId: g.id }).catch(() => [])).length,
637 size: (await chrome.tabs.query({ groupId: g.id }).catch(() => [])).length,
760 })),
638 })),
761 ),
639 ),
762− groupsSupported: GROUPS_SUPPORTED,
763 extensionId: chrome.runtime.id,
640 extensionId: chrome.runtime.id,
764 manifestPath: HOST_MANIFEST_PATH,
641 manifestPath: HOST_MANIFEST_PATH,
765 };
642 };
src/background/naming.ts+29−87
@@ -1,7 +1,6 @@
1−import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
1+import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, LABEL_MAX_CHARS } from "../shared/label";
2import { sha1 } from "../shared/sha1";
2import { sha1 } from "../shared/sha1";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
3import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4−import { pathKey } from "./engine";
5import type { HostOpts, HostReply } from "./host";
4import type { HostOpts, HostReply } from "./host";
6import { SETUP_ERRORS } from "./host";
5import { SETUP_ERRORS } from "./host";
7import type { Settings } from "./settings";
6import type { Settings } from "./settings";
@@ -9,7 +8,7 @@ import type { GroupRecord, HostError, State } from "./state";
9
8
10/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
9/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
11
10
12−export const NAME_ITEM_CAP = 48;
11+export const NAME_ITEM_CAP = 24;
13export const SAMPLE_EACH = 8;
12export const SAMPLE_EACH = 8;
14export const QUEUE_GAP_MS = 250;
13export const QUEUE_GAP_MS = 250;
15export const FALLBACK_ALARM_MS = 30_000;
14export const FALLBACK_ALARM_MS = 30_000;
@@ -50,18 +49,12 @@ export interface NameResult {
50 emoji: string;
49 emoji: string;
51}
50}
52
51
53−/** Tabs that can describe a group: web pages, not the new-tab page or internal pages. */
52+/** Tabs that can describe a group: loaded web pages, not the new-tab page or internal pages. */
54export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
53export const nameable = (m: Member): boolean => !isInternalUrl(m.url);
55
54
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−
62/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
55/** sha-1 of the sorted `url|title` lines: reorder-proof, changes when a member or its title does. */
63export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
56export const membersHash = (members: Pick<Member, "url" | "title">[]): string =>
64− sha1(members.map((m) => `${m.url}|${titleKey(m.title)}`).sort().join("\n"));
57+ sha1(members.map((m) => `${m.url}|${m.title}`).sort().join("\n"));
65
58
66export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
59export const memberUrls = (members: Pick<Member, "url">[]): string[] => members.map((m) => m.url).sort();
67
60
@@ -79,7 +72,7 @@ export function sampleMembers(members: Member[], cap = NAME_ITEM_CAP): Member[]
79export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
72export function toItems(members: Member[], settings: Pick<Settings, "sendDescription" | "sendFullUrl">, withDescriptions = true): NameItem[] {
80 return members.map((m, i) => {
73 return members.map((m, i) => {
81 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
74 const item: NameItem = { i, title: trimText(m.title, 120), url: promptUrl(m.url, settings.sendFullUrl) };
82− const d = withDescriptions && settings.sendDescription ? trimText(m.description, 500) : "";
75+ const d = withDescriptions && settings.sendDescription ? trimText(m.description, 300) : "";
83 if (d) item.description = d;
76 if (d) item.description = d;
84 return item;
77 return item;
85 });
78 });
@@ -114,9 +107,8 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
114 if (g.userNamed) return false;
107 if (g.userNamed) return false;
115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
108 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
116 if (sameLabel(label, g.title)) return false;
109 if (sameLabel(label, g.title)) return false;
117− // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
110+ const before = new Set(g.memberUrls ?? []);
118− const before = new Set((g.memberUrls ?? []).map(pathKey));
111+ const after = new Set(urls);
119− const after = new Set(urls.map(pathKey));
120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
112 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
121 return changed; // a paraphrase with the same members keeps the old title
113 return changed; // a paraphrase with the same members keeps the old title
122}
114}
@@ -216,32 +208,18 @@ export class Naming {
216 }
208 }
217 const now = this.d.now();
209 const now = this.d.now();
218 if (!force) {
210 if (!force) {
219− // A refusal waits for the members to change, which only a look at them can tell.
211+ if (g.nextAttemptAt && g.nextAttemptAt > now) return;
220− if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
212 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
213 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
223 }
214 }
224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
215 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
225 if (!members) return;
216 if (!members) return;
226− if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
217+ if (members.length < 2 || members.some((m) => m.status !== "complete")) return; // stay dirty; next event or alarm
227− if (members.length < 2) {
218+ if (!force && membersHash(members) === g.membersHash) {
228− // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
219+ g.dirty = false;
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);
238 this.d.commit();
220 this.d.commit();
239 return;
221 return;
240 }
222 }
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− }
245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
223 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
224 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
247 void this.pump();
225 void this.pump();
@@ -274,10 +252,10 @@ export class Naming {
274 if (!g || !eligible(g, settings)) return;
252 if (!g || !eligible(g, settings)) return;
275 // Built lazily: a group that changed three times while queued is described once.
253 // Built lazily: a group that changed three times while queued is described once.
276 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
254 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
277− if (!members || members.length < 2 || !members.every(settled)) return;
255+ if (!members || members.length < 2 || members.some((m) => m.status !== "complete")) return;
278 const hash = membersHash(members);
256 const hash = membersHash(members);
279 if (!force && hash === g.membersHash) {
257 if (!force && hash === g.membersHash) {
280− settle(g);
258+ g.dirty = false;
281 this.d.commit();
259 this.d.commit();
282 return;
260 return;
283 }
261 }
@@ -312,10 +290,9 @@ export class Naming {
312 }
290 }
313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
291 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314 retried.output = true;
292 retried.output = true;
315− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
316 continue;
293 continue;
317 }
294 }
318− this.fail(g, e, hash);
295+ this.fail(g, e);
319 return;
296 return;
320 }
297 }
321
298
@@ -324,10 +301,9 @@ export class Naming {
324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
301 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
325 if (!retried.output) {
302 if (!retried.output) {
326 retried.output = true;
303 retried.output = true;
327− mustDifferFrom = withRejected(mustDifferFrom, e.raw);
328 continue;
304 continue;
329 }
305 }
330− this.fail(g, e, hash);
306+ this.fail(g, e);
331 return;
307 return;
332 }
308 }
333 const emoji = safeEmoji(reply.result.emoji);
309 const emoji = safeEmoji(reply.result.emoji);
@@ -342,15 +318,12 @@ export class Naming {
342 finalLabel = suffixLabel(label, siblings);
318 finalLabel = suffixLabel(label, siblings);
343 }
319 }
344 this.d.hostOk();
320 this.d.hostOk();
345− const written = await this.apply(g, finalLabel, emoji, members, hash);
321+ await this.apply(g, finalLabel, emoji, members, hash);
346 g = this.d.state().groups[groupId];
322 g = this.d.state().groups[groupId];
347− if (!g) return;
323+ if (g) {
348− if (!written) {
324+ if (g.dirtySeq === seq) g.dirty = false;
349− // The strip refused the title (a drag in progress, the window closing): try again shortly.
325+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
350− g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
326+ }
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
354 return;
327 return;
355 }
328 }
356 } finally {
329 } finally {
@@ -361,20 +334,16 @@ export class Naming {
361 }
334 }
362 }
335 }
363
336
364− /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
337+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
365− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
366 const settings = this.d.settings();
338 const settings = this.d.settings();
367 const urls = memberUrls(members);
339 const urls = memberUrls(members);
368− if (g.userNamed) return true; // renamed by hand while the model was thinking
340+ if (g.userNamed) return; // renamed by hand while the model was thinking
369 if (shouldApply(g, label, urls)) {
341 if (shouldApply(g, label, urls)) {
370 const title = stripTitle(label, emoji, settings.emoji);
342 const title = stripTitle(label, emoji, settings.emoji);
371 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
343 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
372 this.d.commit();
344 this.d.commit();
373 const ok = await this.d.writeTitle(g.id, title);
345 const ok = await this.d.writeTitle(g.id, title);
374− if (!ok) {
346+ if (!ok) return;
375− delete this.d.state().ownWrites[g.id];
376− return false;
377− }
378 g.title = label;
347 g.title = label;
379 g.emoji = emoji;
348 g.emoji = emoji;
380 g.stripTitle = title;
349 g.stripTitle = title;
@@ -382,24 +351,21 @@ export class Naming {
382 g.lastNamedAt = this.d.now();
351 g.lastNamedAt = this.d.now();
383 g.membersHash = hash;
352 g.membersHash = hash;
384 g.memberUrls = urls;
353 g.memberUrls = urls;
385− clearFailures(g);
354+ g.nameAttempts = 0;
386− return true;
355+ g.firstFailureAt = undefined;
356+ g.nextAttemptAt = undefined;
387 }
357 }
388
358
389− private fail(g: GroupRecord, e: HostError, hash: string): void {
359+ private fail(g: GroupRecord, e: HostError): void {
390 const now = this.d.now();
360 const now = this.d.now();
391 this.d.hostFailed(e);
361 this.d.hostFailed(e);
392 if (e.code === "BAD_REQUEST") {
362 if (e.code === "BAD_REQUEST") {
393 g.dirty = false; // a bug on our side: log and drop
363 g.dirty = false; // a bug on our side: log and drop
394 return;
364 return;
395 }
365 }
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;
399 if (e.code === "GUARDRAIL") {
366 if (e.code === "GUARDRAIL") {
400 g.nameAttempts = MAX_ATTEMPTS;
367 g.nameAttempts = MAX_ATTEMPTS;
401 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
368 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
402− g.refusedHash = hash;
403 this.d.scheduleFallback(g.nextAttemptAt);
369 this.d.scheduleFallback(g.nextAttemptAt);
404 return;
370 return;
405 }
371 }
@@ -409,35 +375,11 @@ export class Naming {
409 g.dirty = false; // retried for a day: keep the provisional title
375 g.dirty = false; // retried for a day: keep the provisional title
410 return;
376 return;
411 }
377 }
412− g.nextAttemptAt = now + backoffFor(g.nameAttempts);
378+ const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
379+ g.nextAttemptAt = now + wait;
413 this.d.scheduleFallback(g.nextAttemptAt);
380 this.d.scheduleFallback(g.nextAttemptAt);
414 }
381 }
415}
382}
416
383
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−
442export const eligible = (g: GroupRecord, settings: Settings): boolean =>
384export const eligible = (g: GroupRecord, settings: Settings): boolean =>
443 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
385 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";
src/background/organize.ts+112−205
@@ -1,19 +1,16 @@
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
1import { COLORS, isColor, type GroupColor } from "../shared/colors";
2−import { labelOf, repairLabel, safeEmoji, stripTitle, titleKey } from "../shared/label";
2+import { labelOf, repairLabel, safeEmoji, stripTitle } from "../shared/label";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
3import { colorFor, isInternalUrl, promptAddress, provisionalTitle } from "../shared/url";
4import { pathKey } from "./engine";
4import { pathKey } from "./engine";
5−import { membersHash, memberUrls, settled, toItems, type Member, type NameItem } from "./naming";
5+import { membersHash, memberUrls, toItems, type Member, type NameItem } from "./naming";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
6import { addToGroup, createManagedGroup, groupExists, ungroup, type Runtime } from "./runtime";
7−import type { HostReply } from "./host";
7+import { markDirty, type State } from "./state";
8−import { markDirty, type GroupRecord, type HostError, type HostErrorCode, type State, type TabRecord } from "./state";
9
8
10/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
9/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
11
10
12−export const ORGANIZE_ITEM_CAP = 36;
11+export const ORGANIZE_ITEM_CAP = 18;
13export const EXISTING_GROUPS_CAP = 12;
12export const EXISTING_GROUPS_CAP = 12;
14−// About the host's 7,000-token budget in English characters. Chinese and Japanese cost about twice the tokens
13+export const CHAR_BUDGET = 10_000;
15−// per character, so their characters count double (weightedLength); the host measures exactly and shortens.
16−export const CHAR_BUDGET = 24_000;
17export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
14export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
18export const EXISTING_GROUP_CHARS = 90;
15export const EXISTING_GROUP_CHARS = 90;
19export const UNDO_WINDOW_MS = 3_600_000;
16export const UNDO_WINDOW_MS = 3_600_000;
@@ -27,8 +24,6 @@ export interface ExistingGroup {
27export interface OrganizePayload {
24export interface OrganizePayload {
28 items: NameItem[];
25 items: NameItem[];
29 existingGroups: ExistingGroup[];
26 existingGroups: ExistingGroup[];
30− /** Every group label in the window: a new group the host names must not repeat one. */
31− siblingTitles?: string[];
32 allowNew: boolean;
27 allowNew: boolean;
33 maxGroups: number;
28 maxGroups: number;
34 minGroupSize: number;
29 minGroupSize: number;
@@ -58,12 +53,9 @@ export const maxGroupsFor = (n: number): number => Math.min(8, Math.ceil(n / 3))
58export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
53export const organizable = (t: { groupId?: number; pinned?: boolean; url?: string; incognito?: boolean }): boolean =>
59 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
54 (t.groupId ?? -1) === -1 && !t.pinned && !t.incognito && !isInternalUrl(t.url);
60
55
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−
64/** Rough rendered size of one item line, the unit the character budget is spent in. */
56/** Rough rendered size of one item line, the unit the character budget is spent in. */
65export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
57export const itemChars = (m: Pick<Member, "title" | "url" | "description">): number =>
66− weightedLength(m.title.slice(0, 120)) + promptAddress(m.url).length + weightedLength((m.description ?? " ").slice(0, 500)) + 12;
58+ Math.min(120, m.title.length) + promptAddress(m.url).length + Math.min(300, m.description?.length ?? 1) + 12;
67
59
68/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
60/** Split tabs (in strip order) into batches of ≤ cap items that also fit the character budget. */
69export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
61export function makeBatches<T extends Pick<Member, "title" | "url" | "description">>(
@@ -154,198 +146,88 @@ export interface OrganizeReport {
154 error?: string;
146 error?: string;
155}
147}
156
148
157−/** Existing groups a batch may add tabs to: Diagonal's topic groups in this window, the ones in use first. */
149+/** Existing groups a batch may add tabs to: extension-managed topic groups in this window. */
158export function existingGroupsFor(state: State, windowId: number, extraIds: number[] = []): { ids: number[]; list: ExistingGroup[] } {
150export 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));
163 const groups = Object.values(state.groups)
151 const groups = Object.values(state.groups)
164− .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && !g.restored && (g.stripTitle || g.title))
152+ .filter((g) => g.windowId === windowId && g.managed && g.origin !== "tidy" && (g.stripTitle || g.title))
165− .map((g) => ({ g, first: extraIds.includes(g.id), at: inUse(g) }))
153+ .sort((a, b) => Number(extraIds.includes(b.id)) - Number(extraIds.includes(a.id)))
166− .sort((a, b) => Number(b.first) - Number(a.first) || b.at - a.at)
154+ .slice(0, EXISTING_GROUPS_CAP);
167− .slice(0, EXISTING_GROUPS_CAP)
168− .map(({ g }) => g);
169 const list = groups.map((g, i) => ({
155 const list = groups.map((g, i) => ({
170 g: i,
156 g: i,
171 title: labelOf(g.stripTitle || g.title),
157 title: labelOf(g.stripTitle || g.title),
172− samples: (members.get(g.id) ?? []).slice(0, 2).map((t) => t.title.slice(0, 60)),
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)),
173 }));
162 }));
174 return { ids: groups.map((g) => g.id), list };
163 return { ids: groups.map((g) => g.id), list };
175}
164}
176
165
177−/** Errors about what was sent rather than about the host: a smaller or plainer request may go through. */
178−const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_MODEL_OUTPUT"]);
179−const MAX_TIMEOUT_SPLITS = 1;
180−
181−/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
182−async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
183− const settings = rt.settings();
184− const s = rt.state();
185− const payload: OrganizePayload = {
186− items: toItems(part, settings, withDescriptions),
187− existingGroups,
188− siblingTitles: Object.values(s.groups)
189− .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190− .map((g) => labelOf(g.stripTitle || g.title))
191− .filter(Boolean)
192− .slice(0, 20),
193− allowNew: part.length >= 2,
194− maxGroups: maxGroupsFor(part.length),
195− minGroupSize: settings.organizeMinGroupSize,
196− };
197− let reply = await rt.host<OrganizeResult>("organize", payload);
198− if (!reply.ok && reply.error.code === "BAD_MODEL_OUTPUT") reply = await rt.host<OrganizeResult>("organize", payload, { strict: true });
199− if (!reply.ok && reply.error.code === "GUARDRAIL" && payload.items.some((i) => i.description)) {
200− // What a page says is the likeliest thing to trip Apple's filter; its title and address rarely do.
201− payload.items = toItems(part, settings, false);
202− reply = await rt.host<OrganizeResult>("organize", payload);
203− }
204− return reply;
205−}
206−
207−/** One organize run over a window: what it made, what it moved, and whether the host stopped it. */
208−export interface SortRun {
209− windowId: number;
210− createdIds: number[];
211− previous: Record<number, number>;
212− grouped: number;
213− left: number;
214− timeoutSplits: number;
215− /** Auto-organize: remember what was looked at, so the same tabs are not sent again unchanged. */
216− stamp: boolean;
217− stopped?: HostError;
218−}
219−
220−export const newRun = (windowId: number, stamp: boolean): SortRun => ({ windowId, createdIds: [], previous: {}, grouped: 0, left: 0, timeoutSplits: 0, stamp });
221−
222−/**
223− * Sort one part of a window. A reply about the tabs themselves (too long, refused by Apple's filter, not
224− * valid output) splits the part in halves, so one page the model won't handle leaves the others sorted; a
225− * tab that fails alone is set aside until its page or title changes. Tabs that changed while the model was
226− * thinking are left out of its answer.
227− */
228−export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe = false): Promise<void> {
229− if (!part.length || run.stopped) return;
230− const { ids: existingIds, list: existingGroups } = existingGroupsFor(rt.state(), run.windowId, run.createdIds);
231− if (part.length < 2 && !existingIds.length && !probe) {
232− run.left += part.length;
233− return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
234− }
235− const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
236− if (!reply.ok) {
237− const e = reply.error;
238− const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
239− if (part.length > 1 && (CONTENT_ERRORS.has(e.code) || timedOut)) {
240− if (timedOut) run.timeoutSplits++;
241− const half = Math.ceil(part.length / 2);
242− await sortPart(rt, part.slice(0, half), run, true);
243− await sortPart(rt, part.slice(half), run, true);
244− return;
245− }
246− if (CONTENT_ERRORS.has(e.code)) {
247− const rec = rt.state().tabs[part[0].id];
248− if (rec) rec.organizeRefused = organizedKeyOf(part[0]);
249− rt.log("organize: set aside a tab the model would not sort", e.code);
250− run.left += 1;
251− return;
252− }
253− run.stopped = e;
254− return;
255− }
256− const settings = rt.settings();
257− const plan = planFromResult(reply.result, part.map((m) => m.id), existingIds, settings.organizeMinGroupSize, maxGroupsFor(part.length), (ids) =>
258− colorFor(part.find((m) => m.id === ids[0])?.url ?? ""),
259− );
260− const unchanged = await stillAsSent(rt, part, run.windowId);
261− const trimmed = trimPlan(rt.state(), plan, unchanged, settings.organizeMinGroupSize);
262− const failed = await applyPlan(rt, trimmed, run.windowId, part, run.previous, run.createdIds);
263− const placed = [...trimmed.joins, ...trimmed.creates].reduce((n, x) => n + x.tabIds.filter((id) => !failed.has(id)).length, 0);
264− run.grouped += placed;
265− run.left += part.length - placed;
266− if (run.stamp) {
267− const s = rt.state();
268− for (const m of part) {
269− const rec = s.tabs[m.id];
270− if (rec && unchanged.has(m.id) && !failed.has(m.id)) rec.organizedKey = organizedKeyOf(m);
271− }
272− }
273−}
274−
275−/** The tabs of a part that are still loose, in this window, on the page and title the model saw. */
276−async function stillAsSent(rt: Runtime, part: Member[], windowId: number): Promise<Set<number>> {
277− const live = new Map((await chrome.tabs.query({ windowId }).catch(() => [])).map((t) => [t.id!, t]));
278− const s = rt.state();
279− return new Set(
280− part
281− .filter((m) => {
282− const t = live.get(m.id);
283− return !!t && organizable(t) && !s.tabs[m.id]?.keepLoose && organizedKeyOf(t) === organizedKeyOf(m);
284− })
285− .map((m) => m.id),
286− );
287−}
288−
289−/** A plan with the tabs that moved on taken out: a new group left too small is not made, a join into a group gone is dropped. */
290−export function trimPlan(state: State, plan: Plan, keep: Set<number>, minGroupSize: number): Plan {
291− const out: Plan = { joins: [], creates: [], leftovers: plan.leftovers.filter((id) => keep.has(id)) };
292− for (const j of plan.joins) {
293− const tabIds = j.tabIds.filter((id) => keep.has(id));
294− const g = state.groups[j.groupId];
295− if (tabIds.length && g?.managed && !g.restored) out.joins.push({ ...j, tabIds });
296− else out.leftovers.push(...tabIds);
297− }
298− for (const c of plan.creates) {
299− const tabIds = c.tabIds.filter((id) => keep.has(id));
300− if (tabIds.length >= Math.max(2, minGroupSize)) out.creates.push({ ...c, tabIds });
301− else out.leftovers.push(...tabIds);
302− }
303− return out;
304−}
305−
306export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
166export async function organizeWindow(rt: Runtime, windowId: number): Promise<OrganizeReport> {
307− const s0 = rt.state();
167+ const settings = rt.settings();
308 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
168 const tabs = (await chrome.tabs.query({ windowId })).filter((t) => organizable(t)).sort((a, b) => a.index - b.index);
309 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
169 const report: OrganizeReport = { grouped: 0, groups: 0, left: 0 };
310− // A tab the model already would not sort, on the same page, is not sent again.
170+ if (tabs.length < 2) {
311− const sendable = tabs.filter((t) => s0.tabs[t.id!]?.organizeRefused !== organizedKeyOf(t));
312− if (sendable.length < 2) {
313 report.left = tabs.length;
171 report.left = tabs.length;
314 return report;
172 return report;
315 }
173 }
316− const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
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);
317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
186 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318 rt.refreshBadge();
187 rt.refreshBadge();
319 try {
188 try {
320− for (const batch of makeBatches(sendable.map((t) => toMember(rt.state(), t)))) {
189+ while (queue.length) {
321− await sortPart(rt, batch, run);
190+ const batch = queue.shift()!;
322− if (run.stopped) break;
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;
323 }
219 }
324 } finally {
220 } finally {
325 const s = rt.state();
221 const s = rt.state();
326 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
222 if (s.inFlight?.groupId === -1) s.inFlight = undefined;
327− report.grouped = run.grouped;
223+ report.groups = createdIds.length;
328− report.groups = run.createdIds.length;
224+ if (Object.keys(previous).length) s.lastOrganize = { at: rt.now(), previous };
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 };
332 rt.commit();
225 rt.commit();
333 rt.refreshBadge();
226 rt.refreshBadge();
334 }
227 }
335 return report;
228 return report;
336}
229}
337
230
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. */
349export async function applyPlan(
231export async function applyPlan(
350 rt: Runtime,
232 rt: Runtime,
351 plan: Plan,
233 plan: Plan,
@@ -353,15 +235,11 @@ export async function applyPlan(
353 members: Member[],
235 members: Member[],
354 previous: Record<number, number>,
236 previous: Record<number, number>,
355 createdIds: number[],
237 createdIds: number[],
356−): Promise<Set<number>> {
238+): Promise<void> {
357 const settings = rt.settings();
239 const settings = rt.settings();
358 const byId = new Map(members.map((m) => [m.id, m]));
240 const byId = new Map(members.map((m) => [m.id, m]));
359− const failed = new Set<number>();
360 for (const j of plan.joins) {
241 for (const j of plan.joins) {
361− if (!(await addToGroup(rt, j.tabIds, j.groupId))) {
242+ if (!(await addToGroup(rt, j.tabIds, j.groupId))) continue;
362− for (const id of j.tabIds) failed.add(id);
363− continue;
364− }
365 for (const id of j.tabIds) previous[id] = -1;
243 for (const id of j.tabIds) previous[id] = -1;
366 const g = rt.state().groups[j.groupId];
244 const g = rt.state().groups[j.groupId];
367 if (g) {
245 if (g) {
@@ -382,10 +260,7 @@ export async function applyPlan(
382 lastNamedAt: named ? rt.now() : undefined,
260 lastNamedAt: named ? rt.now() : undefined,
383 dirty: false,
261 dirty: false,
384 });
262 });
385− if (groupId === undefined) {
263+ if (groupId === undefined) continue;
386− for (const id of c.tabIds) failed.add(id);
387− continue;
388− }
389 createdIds.push(groupId);
264 createdIds.push(groupId);
390 for (const id of c.tabIds) previous[id] = -1;
265 for (const id of c.tabIds) previous[id] = -1;
391 const g = rt.state().groups[groupId];
266 const g = rt.state().groups[groupId];
@@ -394,7 +269,6 @@ export async function applyPlan(
394 rt.naming.touch(groupId);
269 rt.naming.touch(groupId);
395 }
270 }
396 }
271 }
397− return failed;
398}
272}
399
273
400/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
274/** Undo within the hour: every tab goes back to the group it was in (organize only takes loose tabs). */
@@ -422,7 +296,7 @@ export async function undoOrganize(rt: Runtime): Promise<number> {
422}
296}
423
297
424/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
298/** What auto-organize remembers about a tab: it looks again only when the page or title changes. */
425−export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${titleKey(t.title)}`;
299+export const organizedKeyOf = (t: { url?: string; title?: string }): string => `${pathKey(t.url)}\n${t.title ?? ""}`;
426
300
427export const AUTO_RETRY_MS = 300_000;
301export const AUTO_RETRY_MS = 300_000;
428export const AUTO_CONTINUE_MS = 1_500;
302export const AUTO_CONTINUE_MS = 1_500;
@@ -498,27 +372,60 @@ export class AutoOrganizer {
498 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
372 if (pausedUntil && pausedUntil > rt.now()) return; // the host-retry ping sweeps again when it recovers
499 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
373 const tabs = (await chrome.tabs.query({ windowId }).catch(() => [])).sort((a, b) => a.index - b.index);
500 const s = rt.state();
374 const s = rt.state();
501− // Still-loading tabs are skipped here; finishing the load touches the window again. Discarded tabs keep
375+ // Still-loading tabs are skipped here; finishing the load touches the window again.
502− // their title and address, so they're sorted like loaded ones.
376+ const loose = tabs.filter((t) => organizable(t) && t.status === "complete" && !s.tabs[t.id!]?.keepLoose);
503− const loose = tabs.filter((t) => {
504− const rec = s.tabs[t.id!];
505− return organizable(t) && settled(t) && !rec?.keepLoose && rec?.organizeRefused !== organizedKeyOf(t);
506− });
507 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
377 const fresh = loose.filter((t) => s.tabs[t.id!]?.organizedKey !== organizedKeyOf(t));
508 if (!fresh.length) return;
378 if (!fresh.length) return;
509− if (loose.length < 2 && !existingGroupsFor(s, windowId).ids.length) return; // one loose tab and nothing to join: wait for company
379+ const { ids: existingIds, list: existingGroups } = existingGroupsFor(s, windowId);
510− const ordered = [...fresh, ...loose.filter((t) => !fresh.includes(t))].map((t) => toMember(s, t));
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);
511 const batch = makeBatches(ordered)[0];
391 const batch = makeBatches(ordered)[0];
512− const run = newRun(windowId, true);
392+ const maxGroups = maxGroupsFor(batch.length);
513− await sortPart(rt, batch, run);
393+ const payload: OrganizePayload = {
514− rt.commit();
394+ items: toItems(batch, settings),
515− if (run.stopped) {
395+ existingGroups,
516− rt.log("auto-organize", run.stopped.code);
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);
517 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
413 this.deps.scheduleFallback(rt.now() + AUTO_RETRY_MS);
518 return;
414 return;
519 }
415 }
520− const sent = new Set(batch.map((m) => m.id));
416+ const batchIds = batch.map((m) => m.id);
521− if (fresh.some((t) => !sent.has(t.id!))) this.touch(windowId, AUTO_CONTINUE_MS);
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);
522 }
429 }
523}
430}
524
431
src/background/runtime.ts+2−16
@@ -67,48 +67,34 @@ export async function createManagedGroup(
67 extra: Partial<GroupRecord> = {},
67 extra: Partial<GroupRecord> = {},
68): Promise<number | undefined> {
68): Promise<number | undefined> {
69 const s = rt.state();
69 const s = rt.state();
70− const pending = { windowId, origin, at: rt.now() };
70+ s.pendingCreates.push({ windowId, origin, at: rt.now() });
71− s.pendingCreates.push(pending);
72− for (const id of tabIds) s.ownAdds[id] = rt.now();
73 let groupId: number;
71 let groupId: number;
74 try {
72 try {
75 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
73 groupId = await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], createProperties: { windowId } }));
76 } catch (e) {
74 } catch (e) {
77 rt.log("tabs.group failed", e);
75 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);
81 return undefined;
76 return undefined;
82 }
77 }
83 const live = rt.state();
78 const live = rt.state();
84 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
79 const rec = live.groups[groupId] ?? newGroupRecord(groupId, windowId, origin, props.color);
85 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
80 Object.assign(rec, { origin, managed: true, windowId, color: props.color }, extra);
86 live.groups[groupId] = rec;
81 live.groups[groupId] = rec;
87− // Tab records move when tabs.onUpdated reports the new groupId: that event also tells the group
82+ for (const id of tabIds) if (live.tabs[id]) live.tabs[id].groupId = groupId;
88− // each tab left that it lost one, and uses up the ownAdds marker.
89 await updateGroup(rt, groupId, props);
83 await updateGroup(rt, groupId, props);
90 rt.commit();
84 rt.commit();
91 return groupId;
85 return groupId;
92}
86}
93
87
94export async function addToGroup(rt: Runtime, tabIds: number[], groupId: number): Promise<boolean> {
88export 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();
97 try {
89 try {
98 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
90 await withEditRetry(() => chrome.tabs.group({ tabIds: tabIds as [number, ...number[]], groupId }));
99 return true;
91 return true;
100 } catch (e) {
92 } catch (e) {
101 rt.log("tabs.group into existing failed", e);
93 rt.log("tabs.group into existing failed", e);
102− forgetOwnAdds(rt.state(), tabIds);
103 return false;
94 return false;
104 }
95 }
105}
96}
106
97
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−
112export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
98export async function ungroup(rt: Runtime, tabIds: number[]): Promise<void> {
113 if (!tabIds.length) return;
99 if (!tabIds.length) return;
114 try {
100 try {
src/background/selfupdate.tsdeleted−53
@@ -1,53 +0,0 @@
1−/**
2− * `brew upgrade` swaps the unpacked extension's folder for the new release, but the browser keeps running
3− * the old code until someone clicks reload. An unpacked extension serves its files straight from disk, so
4− * the worker can read the manifest that is on disk now and reload itself when it is newer.
5− */
6−export const UPDATE_CHECK_MINUTES = 2;
7−export const TRIED_KEY = "selfUpdateTried";
8−
9−export interface DiskCheck {
10− /** Version of the code running now. */
11− running: string;
12− /** Version in manifest.json on disk, if it could be read. */
13− onDisk?: string;
14− /** background.js on disk could be read too, so the folder is not mid-copy. */
15− filesReady: boolean;
16− /** On-disk version a reload was already tried for; never try the same one twice. */
17− lastTried?: string;
18−}
19−
20−/** Dotted numeric versions ("2026.9.24.512" vs "2026.09.24.0512"): <0, 0 or >0. */
21−export function compareVersions(a: string, b: string): number {
22− const pa = a.split(".").map((n) => Number(n));
23− const pb = b.split(".").map((n) => Number(n));
24− for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
25− const d = (pa[i] ?? 0) - (pb[i] ?? 0);
26− if (d) return d;
27− }
28− return 0;
29−}
30−
31−const VERSION = /^\d+(\.\d+){0,3}$/;
32−
33−export function shouldReload(c: DiskCheck): boolean {
34− if (!c.filesReady || !c.onDisk || !VERSION.test(c.onDisk) || !VERSION.test(c.running)) return false;
35− if (c.lastTried === c.onDisk) return false;
36− return compareVersions(c.onDisk, c.running) > 0;
37−}
38−
39−/** Reads the on-disk manifest and background.js, bypassing any cache. */
40−export async function readDisk(fetchImpl: typeof fetch, url: (path: string) => string): Promise<Pick<DiskCheck, "onDisk" | "filesReady">> {
41− try {
42− const [m, bg] = await Promise.all([
43− fetchImpl(url("manifest.json"), { cache: "no-store" }),
44− fetchImpl(url("background.js"), { cache: "no-store" }),
45− ]);
46− if (!m.ok || !bg.ok) return { filesReady: false };
47− const manifest = (await m.json()) as { version?: unknown };
48− const body = await bg.text();
49− return { onDisk: typeof manifest.version === "string" ? manifest.version : undefined, filesReady: body.length > 0 };
50− } catch {
51− return { filesReady: false };
52− }
53−}
src/background/settings.ts+3−3
@@ -1,7 +1,6 @@
1/** Section 10: every setting, its default and its range. */
1/** Section 10: every setting, its default and its range. */
2export type TidyMode = "ask" | "auto" | "off";
2export type TidyMode = "ask" | "auto" | "off";
3−/** fm on macOS 27 has only the on-device model; a stored "pcc" from older versions reads as "system". */
3+export type ModelChoice = "system" | "pcc";
4−export type ModelChoice = "system";
5
4
6export interface Settings {
5export interface Settings {
7 openerGrouping: boolean;
6 openerGrouping: boolean;
@@ -79,7 +78,8 @@ export function withDefaults(raw: unknown): Settings {
79 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
78 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
80 } else if (Array.isArray(def) && Array.isArray(v)) {
79 } else if (Array.isArray(def) && Array.isArray(v)) {
81 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
80 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
82− } else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
81+ } else if (key === "model" && (v === "system" || v === "pcc")) s.model = v;
82+ else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
83 }
83 }
84 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
84 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
85 return s;
85 return s;
src/background/state.ts+1−26
@@ -10,7 +10,7 @@ export interface TabRecord {
10 index: number;
10 index: number;
11 url: string;
11 url: string;
12 title: string;
12 title: string;
13− description?: string; // what the page says (content/meta.ts), trimmed to 500 chars
13+ description?: string; // trimmed to 300 chars
14 openerTabId?: number;
14 openerTabId?: number;
15 pinned: boolean;
15 pinned: boolean;
16 incognito?: boolean;
16 incognito?: boolean;
@@ -18,12 +18,8 @@ export interface TabRecord {
18 createdAt: number;
18 createdAt: number;
19 lastActivatedAt: number; // epoch ms
19 lastActivatedAt: number; // epoch ms
20 parkedFrom?: string; // title of the group the tab was in before the tidy sweep parked it
20 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
22 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
21 keepLoose?: string; // the user took this tab out of a group on this page: auto-organize leaves it be
23 organizedKey?: string; // page + title when auto-organize last considered this tab
22 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)
27}
23}
28
24
29export interface GroupRecord {
25export interface GroupRecord {
@@ -47,15 +43,8 @@ export interface GroupRecord {
47 nameAttempts: number;
43 nameAttempts: number;
48 firstFailureAt?: number;
44 firstFailureAt?: number;
49 nextAttemptAt?: number;
45 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
54}
46}
55
47
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−
59export interface ArchivedTab {
48export interface ArchivedTab {
60 url: string;
49 url: string;
61 title: string;
50 title: string;
@@ -97,7 +86,6 @@ export interface PingResult {
97 fmMessage: string;
86 fmMessage: string;
98 licenseRequired?: boolean;
87 licenseRequired?: boolean;
99 schemasOk: boolean;
88 schemasOk: boolean;
100− schemaMessage?: string;
101 organizeMode?: string;
89 organizeMode?: string;
102}
90}
103
91
@@ -116,11 +104,6 @@ export interface SweepMove {
116 groupId: number;
104 groupId: number;
117 index: number;
105 index: number;
118 windowId: number;
106 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− >;
124}
107}
125
108
126export interface State {
109export interface State {
@@ -136,10 +119,6 @@ export interface State {
136 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
119 pendingCreates: { windowId: number; origin: GroupOrigin; at: number }[];
137 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
120 /** Tabs the worker itself is ungrouping (dissolve): leaving the group is not a user choice. */
138 ownUngroups: Record<number, number>;
121 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>;
143 lastSweep?: { at: number; moves: SweepMove[] };
122 lastSweep?: { at: number; moves: SweepMove[] };
144 lastOrganize?: { at: number; previous: Record<number, number> };
123 lastOrganize?: { at: number; previous: Record<number, number> };
145 tidyCandidates?: number;
124 tidyCandidates?: number;
@@ -154,8 +133,6 @@ export const emptyState = (): State => ({
154 ownWrites: {},
133 ownWrites: {},
155 pendingCreates: [],
134 pendingCreates: [],
156 ownUngroups: {},
135 ownUngroups: {},
157− ownAdds: {},
158− removedGroups: {},
159});
136});
160
137
161/** Accept whatever was stored and bring it to the current shape. */
138/** Accept whatever was stored and bring it to the current shape. */
@@ -176,8 +153,6 @@ export function migrate(raw: unknown): State {
176 ownWrites: r.ownWrites ?? {},
153 ownWrites: r.ownWrites ?? {},
177 pendingCreates: r.pendingCreates ?? [],
154 pendingCreates: r.pendingCreates ?? [],
178 ownUngroups: r.ownUngroups ?? {},
155 ownUngroups: r.ownUngroups ?? {},
179− ownAdds: r.ownAdds ?? {},
180− removedGroups: r.removedGroups ?? {},
181 };
156 };
182}
157}
183
158
src/background/tidy.ts+17−129
@@ -3,7 +3,7 @@ import { labelOf, stripTitle } from "../shared/label";
3import { isInternalUrl } from "../shared/url";
3import { isInternalUrl } from "../shared/url";
4import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
4import { addToGroup, createManagedGroup, groupExists, ungroup, updateGroup, type Runtime } from "./runtime";
5import type { Settings } from "./settings";
5import type { Settings } from "./settings";
6−import { newGroupRecord, type ArchivedTab, type GroupRecord, type State, type SweepMove } from "./state";
6+import type { ArchivedTab, State, SweepMove } from "./state";
7
7
8/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
8/** Section 7: park stale tabs in a collapsed group, then archive and close them. No model calls. */
9
9
@@ -23,22 +23,11 @@ export interface TidyTab {
23 audible?: boolean;
23 audible?: boolean;
24 incognito?: boolean;
24 incognito?: boolean;
25 discarded?: boolean;
25 discarded?: boolean;
26− autoDiscardable?: boolean;
27 lastAccessed?: number;
26 lastAccessed?: number;
28}
27}
29
28
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− */
34export const idleMs = (tab: TidyTab, state: State, now: number): number =>
29export const idleMs = (tab: TidyTab, state: State, now: number): number =>
35− now - (state.tabs[tab.id]?.lastActivatedAt ?? tab.lastAccessed ?? now);
30+ now - Math.max(tab.lastAccessed ?? 0, state.tabs[tab.id]?.lastActivatedAt ?? 0);
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−};
42
31
43/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
32/** Exclusions are substrings of the URL; a pattern written as /…/ is a regular expression. */
44export function excluded(url: string, patterns: string[]): boolean {
33export function excluded(url: string, patterns: string[]): boolean {
@@ -66,8 +55,7 @@ export function isParkCandidate(tab: TidyTab, state: State, settings: Settings,
66 if (tab.groupId !== -1) {
55 if (tab.groupId !== -1) {
67 const g = state.groups[tab.groupId];
56 const g = state.groups[tab.groupId];
68 if (g?.keep) return false;
57 if (g?.keep) return false;
69− // Groups you made or renamed are yours, like Diagonal's other rules treat them.
58+ if ((!g || g.origin === "user") && !settings.tidyUserGroups) return false;
70− if ((!g || g.origin === "user" || g.userNamed) && !settings.tidyUserGroups) return false;
71 }
59 }
72 if (excluded(tab.url, settings.tidyExclusions)) return false;
60 if (excluded(tab.url, settings.tidyExclusions)) return false;
73 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
61 return idleMs(tab, state, now) >= settings.parkAfterHours * HOUR;
@@ -78,18 +66,10 @@ export function parkCandidates(tabs: TidyTab[], state: State, settings: Settings
78 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
66 return tabs.filter((t) => isParkCandidate(t, state, settings, now, parking));
79}
67}
80
68
81−/** Tabs that have sat in Parked for the archive time, counted from when they were parked. */
82export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
69export function archiveCandidates(tabs: TidyTab[], state: State, settings: Settings, now: number): TidyTab[] {
83 if (!settings.archiveAfterHours) return [];
70 if (!settings.archiveAfterHours) return [];
84 const parking = parkingGroupIds(state);
71 const parking = parkingGroupIds(state);
85− return tabs.filter(
72+ return tabs.filter((t) => parking.has(t.groupId) && !t.active && !t.audible && idleMs(t, state, now) >= settings.archiveAfterHours * HOUR);
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− );
93}
73}
94
74
95/** Prepend entries newest first and drop the oldest past the cap. */
75/** Prepend entries newest first and drop the oldest past the cap. */
@@ -99,15 +79,6 @@ export function addToArchive(archive: ArchivedTab[], entries: ArchivedTab[], cap
99
79
100export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
80export const parkedTitle = (settings: Settings): string => stripTitle("Parked", PARKED_EMOJI, settings.emoji);
101
81
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−
111// ---------------------------------------------------------------------------------------------
82// ---------------------------------------------------------------------------------------------
112
83
113const liveTabs = async (): Promise<TidyTab[]> =>
84const liveTabs = async (): Promise<TidyTab[]> =>
@@ -124,7 +95,6 @@ const liveTabs = async (): Promise<TidyTab[]> =>
124 audible: t.audible,
95 audible: t.audible,
125 incognito: t.incognito,
96 incognito: t.incognito,
126 discarded: t.discarded,
97 discarded: t.discarded,
127− autoDiscardable: t.autoDiscardable,
128 lastAccessed: t.lastAccessed,
98 lastAccessed: t.lastAccessed,
129 }));
99 }));
130
100
@@ -134,20 +104,11 @@ export interface SweepReport {
134 archived: number;
104 archived: number;
135}
105}
136
106
137−let sweeps: Promise<unknown> = Promise.resolve();
138−
139/**
107/**
140 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
108 * The 30-minute sweep (manual = "Tidy now" / "Park N tabs": ignores the threshold and the Ask
141− * consent, still respects eligibility). One at a time: two at once would park, and archive, the
109+ * consent, still respects eligibility).
142− * same tabs twice.
143 */
110 */
144−export function runSweep(rt: Runtime, manual = false): Promise<SweepReport> {
111+export async 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> {
151 const settings = rt.settings();
112 const settings = rt.settings();
152 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
113 const report: SweepReport = { candidates: 0, parked: 0, archived: 0 };
153 if (settings.tidyMode === "off" && !manual) {
114 if (settings.tidyMode === "off" && !manual) {
@@ -157,7 +118,6 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
157 }
118 }
158 const now = rt.now();
119 const now = rt.now();
159 let tabs = await liveTabs();
120 let tabs = await liveTabs();
160− startParkedClocks(rt.state(), tabs, now);
161 const candidates = parkCandidates(tabs, rt.state(), settings, now);
121 const candidates = parkCandidates(tabs, rt.state(), settings, now);
162 report.candidates = candidates.length;
122 report.candidates = candidates.length;
163 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
123 // Auto parks whatever is stale; the threshold only decides when Ask mode puts a count on the badge.
@@ -173,52 +133,8 @@ async function sweep(rt: Runtime, manual: boolean): Promise<SweepReport> {
173 return report;
133 return report;
174}
134}
175
135
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−
219async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
136async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
220 const settings = rt.settings();
137 const settings = rt.settings();
221− const now = rt.now();
222 const moves: SweepMove[] = [];
138 const moves: SweepMove[] = [];
223 const byWindow = new Map<number, TidyTab[]>();
139 const byWindow = new Map<number, TidyTab[]>();
224 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
140 for (const t of candidates) byWindow.set(t.windowId, [...(byWindow.get(t.windowId) ?? []), t]);
@@ -226,15 +142,13 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
226 const ids = tabs.map((t) => t.id);
142 const ids = tabs.map((t) => t.id);
227 const s = rt.state();
143 const s = rt.state();
228 for (const t of tabs) {
144 for (const t of tabs) {
229− const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
145+ moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId });
230− moves.push({ tabId: t.id, groupId: t.groupId, index: t.index, windowId, group: from && groupShape(from) });
231 const rec = s.tabs[t.id];
146 const rec = s.tabs[t.id];
232− if (rec) {
147+ const from = t.groupId !== -1 ? s.groups[t.groupId] : undefined;
233− rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
148+ if (rec) rec.parkedFrom = from ? labelOf(from.stripTitle || from.title) || undefined : undefined;
234− rec.parkedAt = now;
235− }
236 }
149 }
237− let parking = await parkingGroupIn(rt, windowId);
150+ let parking = Object.values(s.groups).find((g) => g.origin === "tidy" && g.windowId === windowId)?.id;
151+ if (parking !== undefined && !(await groupExists(parking))) parking = undefined;
238 if (parking === undefined) {
152 if (parking === undefined) {
239 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
153 parking = await createManagedGroup(rt, ids, windowId, "tidy", { title: parkedTitle(settings), color: "grey", collapsed: true }, { userNamed: true });
240 } else {
154 } else {
@@ -248,16 +162,12 @@ async function parkTabs(rt: Runtime, candidates: TidyTab[]): Promise<number> {
248 rt.log("could not move parking group to the end", e);
162 rt.log("could not move parking group to the end", e);
249 }
163 }
250 if (settings.discardParked) {
164 if (settings.discardParked) {
251− // An extension's discard skips Chromium's own checks, so honour a tab marked "never discard".
252 for (const t of tabs) {
165 for (const t of tabs) {
253− if (!t.discarded && t.autoDiscardable !== false) await chrome.tabs.discard(t.id).catch(() => undefined);
166+ if (!t.discarded) await chrome.tabs.discard(t.id).catch(() => undefined);
254 }
167 }
255 }
168 }
256 }
169 }
257− // Sweeps inside the undo window add up, so Undo puts back everything parked in that hour.
170+ rt.state().lastSweep = { at: rt.now(), moves };
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] };
261 rt.commit();
171 rt.commit();
262 return moves.length;
172 return moves.length;
263}
173}
@@ -287,40 +197,18 @@ export async function undoSweep(rt: Runtime): Promise<number> {
287 const last = rt.state().lastSweep;
197 const last = rt.state().lastSweep;
288 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
198 if (!last || rt.now() - last.at > UNDO_WINDOW_MS) return 0;
289 const live = new Map((await chrome.tabs.query({})).map((t) => [t.id!, t]));
199 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− };
298 let restored = 0;
200 let restored = 0;
299 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
201 for (const m of [...last.moves].sort((a, b) => a.index - b.index)) {
300 const t = live.get(m.tabId);
202 const t = live.get(m.tabId);
301 if (!t) continue;
203 if (!t) continue;
302− let groupId = await target(m);
204+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
303− if (groupId !== undefined) await addToGroup(rt, [m.tabId], groupId);
205+ else await ungroup(rt, [m.tabId]);
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− }
312 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
206 await chrome.tabs.move(m.tabId, { windowId: m.windowId, index: m.index }).catch(() => undefined);
313 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
207 // Moving a tab next to its old neighbours may pull it into a group they share; re-assert.
314− groupId = await target(m);
208+ if (m.groupId !== -1 && (await groupExists(m.groupId))) await addToGroup(rt, [m.tabId], m.groupId);
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− }
320 const rec = rt.state().tabs[m.tabId];
209 const rec = rt.state().tabs[m.tabId];
321 if (rec) {
210 if (rec) {
322 rec.parkedFrom = undefined;
211 rec.parkedFrom = undefined;
323− rec.parkedAt = undefined;
324 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
212 rec.lastActivatedAt = rt.now(); // un-parking is a touch: do not re-park on the next sweep
325 }
213 }
326 restored++;
214 restored++;
@@ -361,7 +249,7 @@ export async function restoreAll(rt: Runtime, windowId: number): Promise<number>
361 if (t.id !== undefined) ids.push(t.id);
249 if (t.id !== undefined) ids.push(t.id);
362 }
250 }
363 if (ids.length) {
251 if (ids.length) {
364− await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true, restored: true });
252+ await createManagedGroup(rt, ids, windowId, "organize", { title: stripTitle("Restored", RESTORED_EMOJI, rt.settings().emoji), color: "grey" }, { userNamed: true });
365 }
253 }
366 return ids.length;
254 return ids.length;
367}
255}
src/content/meta.ts+5−31
@@ -1,37 +1,11 @@
1/**
1/**
2− * Runs once at document_idle on http(s) pages and hands the worker a short summary of what the page is
2+ * Runs once at document_idle on http(s) pages: reads the page's description meta tag
3− * about, up to 500 characters: the description meta tag (or og:description), the main heading when it adds
3+ * (falling back to og:description), trims it to 300 characters and hands it to the worker.
4− * to the tab title, and the opening lines of the page's main text. It stays on this Mac: the worker only
4+ * No DOM writes, no listeners, no page body text.
5− * passes it to Apple's on-device model, and only while "Include what pages say" is on.
6− * No DOM writes, no listeners, nothing from form fields.
7 */
5 */
8(() => {
6(() => {
9− const squash = (s: string | null | undefined) => (s ?? "").replace(/\s+/g, " ").trim();
7+ const read = (sel: string) => document.querySelector<HTMLMetaElement>(sel)?.content?.replace(/\s+/g, " ").trim() ?? "";
10− const meta = (sel: string) => squash(document.querySelector<HTMLMetaElement>(sel)?.content);
8+ const description = (read('meta[name="description" i]') || read('meta[property="og:description" i]')).slice(0, 300);
11− const parts: string[] = [];
12− const add = (s: string, max: number) => {
13− const t = s.slice(0, max);
14− if (t && !parts.some((p) => p.includes(t) || t.includes(p))) parts.push(t);
15− };
16−
17− add(meta('meta[name="description" i]') || meta('meta[property="og:description" i]'), 300);
18−
19− const h1 = squash(document.querySelector("h1")?.textContent);
20− if (h1 && !document.title.toLowerCase().includes(h1.toLowerCase())) add(h1, 120);
21−
22− // Opening paragraphs of the main content, skipping navigation, short captions and cookie banners.
23− const root = document.querySelector("main, article, [role=main]") ?? document.body;
24− let text = "";
25− for (const p of Array.from(root?.querySelectorAll("p") ?? []).slice(0, 30)) {
26− if (p.closest("nav, header, footer, aside, form, [role=dialog], [aria-modal=true]")) continue;
27− const line = squash(p.textContent);
28− if (line.length < 40 || /cookie|consent|javascript/i.test(line)) continue;
29− text += (text ? " " : "") + line;
30− if (text.length >= 240) break;
31− }
32− add(text, 240);
33−
34− const description = parts.join(" · ").slice(0, 500);
35 if (!description) return;
9 if (!description) return;
36 try {
10 try {
37 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
11 chrome.runtime.sendMessage({ kind: "meta", description }).catch(() => undefined);
src/options/options.html+35−71
@@ -5,80 +5,44 @@
5 <title>Diagonal settings</title>
5 <title>Diagonal settings</title>
6 <link rel="stylesheet" href="ui.css">
6 <link rel="stylesheet" href="ui.css">
7 <style>
7 <style>
8− body { font-size: 13.5px; }
8+ body { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
9− .page { max-width: 680px; margin: 0 auto; padding: 40px 20px 64px; display: grid; gap: 16px; }
9+ h1 { font-size: 20px; }
10− header { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; }
10+ section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px 16px 12px; margin-top: 16px; }
11− header h1 { font-size: 22px; }
11+ .field { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
12− header p { color: var(--muted); margin-top: 2px; }
12+ .field:last-child { border-bottom: 0; }
13−
13+ .field .help { grid-column: 1 / -1; color: var(--muted); font-size: 12px; }
14− .card > .head { padding: 16px 18px 4px; }
14+ input[type=number], select { font: inherit; width: auto; min-width: 90px; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
15− .card > .head p { color: var(--muted); margin-top: 4px; }
15+ textarea { font: 12px ui-monospace, Menlo, monospace; width: 100%; min-height: 70px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); padding: 6px; }
16− .field { display: grid; grid-template-columns: 1fr auto; gap: 2px 24px; align-items: center; padding: 12px 18px; border-top: 1px solid var(--border); }
16+ pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
17− .card > .head + .field { border-top: 0; }
17+ .warn-text { color: var(--warn); }
18− .field label, .field .label { font-weight: 500; }
18+ #saved { transition: opacity .3s; }
19− .field .help { grid-column: 1; color: var(--muted); font-size: 12.5px; }
20− .field .wide { grid-column: 1 / -1; margin-top: 6px; }
21− .field.off label, .field.off .help { opacity: .5; }
22− .field .warn-text { grid-column: 1 / -1; color: var(--warn); font-size: 12.5px; margin-top: 4px; }
23−
24− .unit { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); overflow: hidden; }
25− .unit input { width: 64px; border: 0; background: none; color: var(--text); font: inherit; padding: 5px 4px 5px 10px; text-align: right; }
26− .unit input:focus { outline: none; }
27− .unit:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
28− .unit span { color: var(--muted); padding: 0 10px 0 4px; font-size: 12.5px; }
29− select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
30− select { padding: 5px 8px; }
31− select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
32− textarea { width: 100%; min-height: 116px; padding: 8px 10px; font: 12px/1.6 ui-monospace, "SF Mono", Menlo, monospace; resize: vertical; }
33− input:disabled, select:disabled, textarea:disabled { opacity: .5; }
34−
35− .conn { display: flex; align-items: center; gap: 14px; padding: 16px 18px; }
36− .conn .title { font-weight: 600; }
37−
38− details.card > summary { list-style: none; cursor: pointer; padding: 14px 18px; display: flex; align-items: center; gap: 8px; }
39− details.card > summary::-webkit-details-marker { display: none; }
40− pre { margin: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace; }
41− .foot { text-align: center; color: var(--faint); font-size: 12px; margin-top: 8px; }
42 </style>
19 </style>
43</head>
20</head>
44<body>
21<body>
45− <div class="page">
22+ <div class="row"><h1 class="grow">Diagonal settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
46− <header>
23+ <p class="muted">Tab groups named by Apple's on-device model through the <code>fm</code> command-line tool. Nothing is sent over the network.</p>
47− <img class="logo" src="icons/128.png" width="48" height="48" alt="">
24+
48− <div class="grow">
25+ <section>
49− <h1>Diagonal</h1>
26+ <h2>Host setup and self-test</h2>
50− <p>Groups your tabs as you browse and names them with Apple's on-device model.</p>
27+ <div class="field"><span>Extension ID</span><code id="ext-id"></code></div>
51− </div>
28+ <div class="field"><span>Host manifest path</span><button class="link" id="copy-path">Copy</button><code class="help" id="manifest-path"></code></div>
52− <span class="pill warn busy" id="status"><span class="dot"></span><span id="status-text">Connecting…</span></span>
29+ <div class="field"><span>Host manifest contents</span><button class="link" id="copy-manifest">Copy</button><pre class="help" id="manifest-json"></pre></div>
53− </header>
30+ <div class="field"><span>Self-test: ping the host, then name three fixture tabs</span><button class="primary" id="selftest">Run self-test</button><pre class="help" id="selftest-out" hidden></pre></div>
54−
31+ <div class="field"><span>Naming latency (last 100 calls)</span><span id="latency" class="mono muted">no data yet</span></div>
55− <div id="notices" style="display:grid;gap:8px" hidden></div>
32+ <div class="field"><span>Last host error</span><span></span><pre class="help" id="last-error">none</pre></div>
56−
33+ </section>
57− <div class="card conn">
34+
58− <div class="grow">
35+ <section id="settings"></section>
59− <div class="title">Connection to the model</div>
36+
60− <div class="muted" id="selftest-summary">Check that the helper answers and can name a sample group.</div>
37+ <section>
61− </div>
38+ <h2>Archive</h2>
62− <button id="selftest">Run check</button>
39+ <div class="field"><span>Export every archived tab as JSON</span><button id="export">Export archive</button></div>
63− </div>
40+ </section>
64−
41+
65− <div id="settings" style="display:grid;gap:16px"></div>
42+ <section id="debug-section" hidden>
66−
43+ <h2>Debug log</h2>
67− <details class="card" id="troubleshooting">
44+ <pre id="debug-log"></pre>
68− <summary><span class="chev"></span><h2 class="grow">Troubleshooting</h2></summary>
45+ </section>
69− <div class="field"><span class="label">Extension ID</span><code id="ext-id"></code></div>
70− <div class="field"><span class="label">Naming speed</span><span id="latency" class="mono muted">No calls yet</span><span class="help">Median and slowest 5% of the last 100 model calls.</span></div>
71− <div class="field"><span class="label">Host manifest path</span><button class="link small" id="copy-path">Copy</button><code class="wide muted" id="manifest-path"></code></div>
72− <div class="field"><span class="label">Host manifest</span><button class="link small" id="copy-manifest">Copy</button><pre class="wide" id="manifest-json"></pre></div>
73− <div class="field"><span class="label">Last check, in full</span><span></span><pre class="wide" id="selftest-out">Not run yet</pre></div>
74− <div class="field"><span class="label">Last host error</span><span></span><pre class="wide" id="last-error">None</pre></div>
75− <div id="debug-fields"></div>
76− <div class="field" id="debug-section" hidden><span class="label">Debug log</span><span></span><pre class="wide" id="debug-log"></pre></div>
77− </details>
78−
79− <p class="foot">Changes save as you make them.</p>
80− </div>
81− <div class="toast" id="saved" role="status">Saved</div>
82 <script src="options.js" type="module"></script>
46 <script src="options.js" type="module"></script>
83</body>
47</body>
84</html>
48</html>
src/options/options.ts+100−184
@@ -1,155 +1,107 @@
1import { DEFAULT_SETTINGS, withDefaults, type Settings } from "../background/settings";
1import { DEFAULT_SETTINGS, withDefaults, type Settings } from "../background/settings";
2−import { notices, renderNotice, renderPill, type Health } from "../shared/health";
3import { el, send } from "../shared/messaging";
2import { el, send } from "../shared/messaging";
4
3
5const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
4const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
6
5
7−type Field = { key: keyof Settings; label: string; help?: string; when?: (s: Settings) => boolean } & (
6+type Field =
8− | { kind: "bool" }
7+ | { key: keyof Settings; label: string; help?: string; kind: "bool" }
9− | { kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
8+ | { key: keyof Settings; label: string; help?: string; kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
10− | { kind: "select"; options: [string, string][] }
9+ | { key: keyof Settings; label: string; help?: string; kind: "select"; options: [string, string][] }
11− | { kind: "list" }
10+ | { key: keyof Settings; label: string; help?: string; kind: "list" };
12−);
11+
13−
12+const SECTIONS: [string, Field[]][] = [
14−interface Section { title: string; intro: string; fields: Field[]; extra?: () => HTMLElement }
13+ ["Grouping", [
15−
14+ { key: "autoOrganize", label: "Organize loose tabs into groups automatically", help: "Tabs you take out of a group stay out until they go to another page.", kind: "bool" },
16−const SECTIONS: Section[] = [
15+ { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, step: 1, unit: "s", scale: 1000 },
17− {
16+ { key: "openerGrouping", label: "Group a tab with the tab it was opened from", kind: "bool" },
18− title: "Grouping",
17+ { key: "dissolveSingletons", label: "Ungroup Diagonal's groups that drop to one tab", kind: "bool" },
19− intro: "Tabs join a group on their own. Nothing to press.",
18+ ]],
20− fields: [
19+ ["Naming", [
21− { key: "openerGrouping", label: "Keep a tab with the one it was opened from", kind: "bool" },
20+ { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
22− { key: "autoOrganize", label: "Sort loose tabs into groups", help: "A tab you pull out of a group stays out until it goes to another page.", kind: "bool" },
21+ { key: "nameUserGroups", label: "Name my own groups too", help: "Stops for good once you type a title.", kind: "bool" },
23− { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, unit: "sec", scale: 1000, when: (s) => s.autoOrganize },
22+ { key: "emoji", label: "Prefix titles with an emoji", kind: "bool" },
24− { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs", when: (s) => s.autoOrganize },
23+ { key: "namingDebounceMs", label: "Quiet period before naming", kind: "number", min: 1, max: 30, step: 1, unit: "s", scale: 1000 },
25− { key: "dissolveSingletons", label: "Ungroup a group that's down to one tab", help: "Only groups Diagonal made.", kind: "bool" },
24+ { key: "sendDescription", label: "Send page descriptions to the model", help: "Off: title and address only.", kind: "bool" },
26− ],
25+ { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
27− },
26+ { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
28− {
27+ { key: "timeoutMs", label: "Timeout per model call", kind: "number", min: 5, max: 120, step: 1, unit: "s", scale: 1000 },
29− title: "Naming",
28+ { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs" },
30− intro: "Groups get a short title from the model on this Mac.",
29+ ]],
31− fields: [
30+ ["Tidy", [
32− { key: "naming", label: "Name groups automatically", kind: "bool" },
31+ { key: "tidyMode", label: "Tidy sweep", kind: "select", options: [["auto", "Park automatically"], ["ask", "Ask before parking"], ["off", "Off"]] },
33− { key: "emoji", label: "Start titles with an emoji", kind: "bool", when: (s) => s.naming },
32+ { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
34− { key: "nameUserGroups", label: "Name groups I make too", help: "Stops for good once you type a title yourself.", kind: "bool", when: (s) => s.naming },
33+ { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 = never archive.", kind: "number", min: 0, max: 2160, unit: "h" },
35− { key: "namingDebounceMs", label: "Wait before naming", kind: "number", min: 1, max: 30, unit: "sec", scale: 1000, when: (s) => s.naming },
34+ { key: "tidyThreshold", label: "Ask mode: offer a sweep at", kind: "number", min: 1, max: 100, unit: "tabs" },
36− { key: "timeoutMs", label: "Give the model up to", kind: "number", min: 5, max: 120, unit: "sec", scale: 1000, when: (s) => s.naming },
35+ { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
37− ],
36+ { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
38− },
37+ { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Write /…/ for a regular expression.", kind: "list" },
39− {
38+ { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
40− title: "Privacy",
39+ ]],
41− intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
40+ ["Diagnostics", [
42− fields: [
41+ { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Diagonal/host.log when ~/Library/Application Support/Diagonal/debug exists.", kind: "bool" },
43− { key: "sendDescription", label: "Include what pages say", help: "Each page's description, main heading and opening lines. Off: titles and addresses only.", kind: "bool" },
42+ ]],
44− { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
45− ],
46− },
47− {
48− title: "Tidy",
49− intro: "Tabs you haven't touched in a while move to a parked group, then to the archive.",
50− fields: [
51− { key: "tidyMode", label: "Tidy old tabs", kind: "select", options: [["auto", "Automatically"], ["ask", "Ask me first"], ["off", "Never"]] },
52− { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "hours", when: (s) => s.tidyMode !== "off" },
53− { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 keeps them parked.", kind: "number", min: 0, max: 2160, unit: "hours", when: (s) => s.tidyMode !== "off" },
54− { key: "tidyThreshold", label: "Ask once this many are waiting", kind: "number", min: 1, max: 100, unit: "tabs", when: (s) => s.tidyMode === "ask" },
55− { key: "discardParked", label: "Free memory used by parked tabs", kind: "bool", when: (s) => s.tidyMode !== "off" },
56− { key: "tidyUserGroups", label: "Tidy inside groups I made", kind: "bool", when: (s) => s.tidyMode !== "off" },
57− { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Wrap in /…/ for a regular expression.", kind: "list", when: (s) => s.tidyMode !== "off" },
58− ],
59− },
60− {
61− title: "Archive",
62− intro: "Archived tabs can be restored from the toolbar popup.",
63− fields: [{ key: "archiveCap", label: "Keep up to", kind: "number", min: 50, max: 5000, unit: "tabs" }],
64− extra: () =>
65− el("div", { className: "field" },
66− el("span", { className: "label", textContent: "Export the archive" }),
67− el("button", { textContent: "Export JSON", onclick: exportArchive }),
68− el("span", { className: "help", textContent: "Every archived tab with its title, address and group." })),
69− },
70];
43];
71
44
72−const DEBUG_FIELD: Field = {
73− key: "debugLog", label: "Debug logging", kind: "bool",
74− help: "The extension logs to its console. The helper logs to ~/Library/Logs/Diagonal/host.log once ~/Library/Application Support/Diagonal/debug exists.",
75−};
76−
77let settings: Settings = DEFAULT_SETTINGS;
45let settings: Settings = DEFAULT_SETTINGS;
78−const rows: { field: Field; row: HTMLElement }[] = [];
79−let toastTimer: ReturnType<typeof setTimeout> | undefined;
80
46
81async function save(patch: Partial<Settings>): Promise<void> {
47async function save(patch: Partial<Settings>): Promise<void> {
82 settings = withDefaults({ ...settings, ...patch });
48 settings = withDefaults({ ...settings, ...patch });
83 await chrome.storage.local.set({ settings });
49 await chrome.storage.local.set({ settings });
84− const t = $("saved");
50+ const s = $("saved");
85− t.classList.add("show");
51+ s.style.opacity = "1";
86− clearTimeout(toastTimer);
52+ setTimeout(() => (s.style.opacity = "0"), 1200);
87− toastTimer = setTimeout(() => t.classList.remove("show"), 1200);
53+ renderWarnings();
88− applyDependencies();
89− $("debug-section").hidden = !settings.debugLog;
90}
54}
91
55
92−/** Dim and disable settings that don't apply with the current choices. */
56+function renderWarnings(): void {
93−function applyDependencies(): void {
57+ const w = document.getElementById("pcc-warning");
94− for (const { field, row } of rows) {
58+ if (w) w.hidden = settings.model !== "pcc";
95− const on = field.when ? field.when(settings) : true;
96− row.classList.toggle("off", !on);
97− row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
98− }
99−}
100−
101−function fieldRow(f: Field): HTMLElement {
102− const value = settings[f.key] as unknown;
103− const id = `f-${f.key}`;
104− let control: HTMLElement;
105− if (f.kind === "bool") {
106− control = el("input", { id, type: "checkbox", className: "switch", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
107− } else if (f.kind === "number") {
108− const scale = f.scale ?? 1;
109− control = el("span", { className: "unit" },
110− el("input", {
111− id, type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
112− onchange: (e: Event) => {
113− const input = e.target as HTMLInputElement;
114− const n = Math.min(f.max, Math.max(f.min, Number(input.value) || f.min));
115− input.value = String(n);
116− void save({ [f.key]: n * scale });
117− },
118− }),
119− el("span", { textContent: f.unit ?? "" }));
120− } else if (f.kind === "select") {
121− const sel = el("select", { id, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
122− for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
123− control = sel;
124− } else {
125− control = el("span");
126− }
127− const row = el("div", { className: "field" }, el("label", { htmlFor: id, textContent: f.label }), control);
128− if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
129− if (f.kind === "list") {
130− row.append(el("textarea", {
131− id, className: "wide", spellcheck: false,
132− value: (value as string[]).join("\n"),
133− onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
134− }));
135− }
136− rows.push({ field: f, row });
137− return row;
138}
59}
139
60
140function renderSettings(): void {
61function renderSettings(): void {
141− rows.length = 0;
142 const root = $("settings");
62 const root = $("settings");
143 root.replaceChildren();
63 root.replaceChildren();
144− for (const s of SECTIONS) {
64+ for (const [title, fields] of SECTIONS) {
145− const card = el("section", { className: "card" },
65+ root.append(el("h2", { textContent: title }));
146− el("div", { className: "head" }, el("h2", { textContent: s.title }), el("p", { textContent: s.intro })));
66+ for (const f of fields) {
147− for (const f of s.fields) card.append(fieldRow(f));
67+ const value = settings[f.key] as unknown;
148− if (s.extra) card.append(s.extra());
68+ let control: HTMLElement;
149− root.append(card);
69+ if (f.kind === "bool") {
70+ control = el("input", { type: "checkbox", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
71+ } else if (f.kind === "number") {
72+ const scale = f.scale ?? 1;
73+ control = el("span", {},
74+ el("input", {
75+ type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
76+ onchange: (e: Event) => save({ [f.key]: Number((e.target as HTMLInputElement).value) * scale }),
77+ }),
78+ f.unit ? ` ${f.unit}` : "");
79+ } else if (f.kind === "select") {
80+ const sel = el("select", { onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
81+ for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
82+ control = sel;
83+ } else {
84+ control = el("span");
85+ }
86+ const row = el("div", { className: "field" }, el("label", { textContent: f.label }), control);
87+ if (f.kind === "list") {
88+ row.append(el("textarea", {
89+ className: "help",
90+ value: (value as string[]).join("\n"),
91+ onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
92+ }));
93+ }
94+ if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
95+ if (f.key === "model") {
96+ row.append(el("div", {
97+ className: "help warn-text", id: "pcc-warning",
98+ textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. The on-device model keeps them on this Mac.",
99+ }));
100+ }
101+ root.append(row);
102+ }
150 }
103 }
151− $("debug-fields").replaceChildren(fieldRow(DEBUG_FIELD));
104+ renderWarnings();
152− applyDependencies();
153}
105}
154
106
155function percentile(values: number[], p: number): number {
107function percentile(values: number[], p: number): number {
@@ -157,59 +109,23 @@ function percentile(values: number[], p: number): number {
157 return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
109 return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
158}
110}
159
111
160−async function renderHealth(): Promise<void> {
161− try {
162− const s = await send<Health>("status");
163− renderPill($("status"), s);
164− const list = notices(s);
165− $("notices").replaceChildren(...list.map(renderNotice));
166− $("notices").hidden = list.length === 0;
167− } catch {
168− /* worker asleep or reloading */
169− }
170−}
171−
172async function renderDiagnostics(): Promise<void> {
112async function renderDiagnostics(): Promise<void> {
173 try {
113 try {
174 const d = await send<{ latency: number[]; debugLog: { at: number; msg: string }[]; host: { lastError?: unknown } }>("diagnostics");
114 const d = await send<{ latency: number[]; debugLog: { at: number; msg: string }[]; host: { lastError?: unknown } }>("diagnostics");
175− if (d.latency.length) $("latency").textContent = `${percentile(d.latency, 50)} ms · ${percentile(d.latency, 95)} ms · ${d.latency.length} calls`;
115+ if (d.latency.length) $("latency").textContent = `p50 ${percentile(d.latency, 50)} ms · p95 ${percentile(d.latency, 95)} ms · n=${d.latency.length}`;
176− $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "None";
116+ $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "none";
177 $("debug-section").hidden = !settings.debugLog;
117 $("debug-section").hidden = !settings.debugLog;
178− $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "Empty";
118+ $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "empty";
179 } catch {
119 } catch {
180 /* worker asleep or reloading */
120 /* worker asleep or reloading */
181 }
121 }
182}
122}
183
123
184−async function exportArchive(): Promise<void> {
185− const archive = await send("exportArchive");
186− const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
187− const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
188− a.click();
189− setTimeout(() => URL.revokeObjectURL(a.href), 1000);
190−}
191−
192−type SelfTest = {
193− ping?: { ok: boolean; result?: { fmAvailable?: boolean }; error?: { message?: string } };
194− pingMs?: number;
195− name?: { ok: boolean; result?: { title?: string; emoji?: string }; error?: { message?: string } };
196− nameMs?: number;
197−};
198−
199−function summarize(r: SelfTest): string {
200− if (!r.ping?.ok) return `The helper didn't answer: ${r.ping?.error?.message ?? "no reply"}`;
201− if (!r.name) return `The helper answered in ${r.pingMs} ms.`;
202− if (!r.name.ok) return `The helper answered, but naming failed: ${r.name.error?.message ?? "no reply"}`;
203− const title = [r.name.result?.emoji, r.name.result?.title].filter(Boolean).join(" ");
204− return `Working. A sample group was named “${title}” in ${r.nameMs} ms.`;
205−}
206−
207async function main(): Promise<void> {
124async function main(): Promise<void> {
208 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
125 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
209 renderSettings();
126 renderSettings();
210 const id = chrome.runtime.id;
127 const id = chrome.runtime.id;
211− // Each Chromium browser has its own folder: Google/Chrome, BraveSoftware/Brave-Browser, BraveSoftware/Brave-Origin, …
128+ const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json";
212− const path = "~/Library/Application Support/<browser>/NativeMessagingHosts/io.diagonal.host.json";
213 const manifest = JSON.stringify({
129 const manifest = JSON.stringify({
214 name: "io.diagonal.host",
130 name: "io.diagonal.host",
215 description: "Diagonal: names tab groups with Apple's on-device model",
131 description: "Diagonal: names tab groups with Apple's on-device model",
@@ -223,30 +139,30 @@ async function main(): Promise<void> {
223 $("copy-path").onclick = () => navigator.clipboard.writeText(path);
139 $("copy-path").onclick = () => navigator.clipboard.writeText(path);
224 $("copy-manifest").onclick = () => navigator.clipboard.writeText(manifest);
140 $("copy-manifest").onclick = () => navigator.clipboard.writeText(manifest);
225 $("selftest").onclick = async () => {
141 $("selftest").onclick = async () => {
226− const b = $<HTMLButtonElement>("selftest");
227 const out = $("selftest-out");
142 const out = $("selftest-out");
228− const summary = $("selftest-summary");
143+ out.hidden = false;
229− b.disabled = true;
144+ out.textContent = "Running…";
230− b.textContent = "Checking…";
231− summary.textContent = "Asking the helper to name a sample group…";
232 try {
145 try {
233− const r = await send<SelfTest>("selftest");
146+ const r = await send("selftest");
234 out.textContent = JSON.stringify(r, null, 2);
147 out.textContent = JSON.stringify(r, null, 2);
235− summary.textContent = summarize(r);
236 } catch (e) {
148 } catch (e) {
237 out.textContent = String(e);
149 out.textContent = String(e);
238− summary.textContent = `The check couldn't run: ${e instanceof Error ? e.message : String(e)}`;
239 }
150 }
240− b.disabled = false;
151+ await renderDiagnostics();
241− b.textContent = "Run check";
152+ };
242− await Promise.all([renderHealth(), renderDiagnostics()]);
153+ $("export").onclick = async () => {
154+ const archive = await send("exportArchive");
155+ const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
156+ const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
157+ a.click();
158+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
243 };
159 };
244 chrome.storage.onChanged.addListener((changes, area) => {
160 chrome.storage.onChanged.addListener((changes, area) => {
245 if (area === "local" && changes.settings) {
161 if (area === "local" && changes.settings) {
246 settings = withDefaults(changes.settings.newValue);
162 settings = withDefaults(changes.settings.newValue);
247 }
163 }
248 });
164 });
249− await Promise.all([renderHealth(), renderDiagnostics()]);
165+ await renderDiagnostics();
250}
166}
251
167
252void main();
168void main();
src/popup/popup.html+30−71
@@ -5,81 +5,40 @@
5 <title>Diagonal</title>
5 <title>Diagonal</title>
6 <link rel="stylesheet" href="ui.css">
6 <link rel="stylesheet" href="ui.css">
7 <style>
7 <style>
8− body { width: 340px; background: var(--bg); }
8+ body { width: 360px; padding: 12px 14px 14px; }
9− header { display: flex; align-items: center; gap: 10px; padding: 14px 14px 12px; }
9+ .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
10− main { padding: 0 10px 10px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
10+ .group-actions button { padding: 2px 6px; font-size: 12px; }
11− .summary { padding: 12px 14px; display: grid; gap: 2px; }
11+ #archive { max-height: 220px; overflow-y: auto; }
12− .summary .big { font-size: 14px; font-weight: 600; }
12+ #groups { max-height: 200px; overflow-y: auto; }
13− .section { padding: 4px 0; }
13+ .count { font-variant-numeric: tabular-nums; }
14− .section > .head { display: flex; align-items: center; padding: 8px 14px 4px; }
15− .list { list-style: none; margin: 0; padding: 0 4px; }
16− .scroll { max-height: 232px; overflow-y: auto; }
17−
18− details.group > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; cursor: pointer; }
19− details.group > summary::-webkit-details-marker { display: none; }
20− details.group > summary:hover, details.group[open] > summary { background: var(--surface-2); }
21− details.group .count { color: var(--faint); font-size: 12px; }
22− details.group .more { padding: 4px 10px 10px 30px; display: grid; gap: 6px; }
23− details.group .acts { display: flex; flex-wrap: wrap; gap: 4px; }
24− .tag { font-size: 11px; color: var(--muted); }
25−
26− .arch { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: 8px; }
27− .arch:hover { background: var(--surface-2); }
28− .arch .acts { display: flex; align-items: center; gap: 0; }
29− .arch .forget { color: var(--faint); font-size: 15px; line-height: 1; padding: 2px 6px; }
30− .arch .sub { font-size: 11px; color: var(--faint); }
31− .empty { padding: 6px 14px 10px; color: var(--muted); }
32−
33− .result { padding: 9px 12px; background: var(--accent-soft); border-radius: var(--radius); display: flex; align-items: center; gap: 8px; }
34− .result .grow { color: var(--text); }
35− .park { padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
36−
37− footer { display: flex; align-items: center; gap: 4px; padding: 8px 10px 12px; border-top: 1px solid var(--border); }
38 </style>
14 </style>
39</head>
15</head>
40<body>
16<body>
41− <header>
17+ <div class="row">
42− <img class="logo" src="icons/48.png" width="26" height="26" alt="">
43 <h1 class="grow">Diagonal</h1>
18 <h1 class="grow">Diagonal</h1>
44− <span class="pill warn busy" id="status"><span class="dot"></span><span id="status-text">Connecting…</span></span>
19+ <span class="pill" id="status"><span class="dot" id="status-dot"></span><span id="status-text">Checking…</span></span>
45− </header>
20+ </div>
46−
21+ <div id="notice" class="notice" hidden></div>
47− <main>
22+
48− <div id="notices" class="notices" style="display:grid;gap:8px" hidden></div>
23+ <div class="actions">
49−
24+ <button id="organize">Organize now</button>
50− <div class="card summary">
25+ <button id="tidy">Tidy now</button>
51− <div class="big" id="summary-title">Keeping this window tidy</div>
26+ <button id="park" hidden></button>
52− <div class="muted" id="summary-sub">Related tabs gather into named groups as you browse.</div>
27+ </div>
53− </div>
28+ <div class="actions">
54−
29+ <button class="link" id="undo-organize" hidden>Undo organize</button>
55− <div class="card park" id="park-card" hidden>
30+ <button class="link" id="undo-sweep" hidden>Undo last sweep</button>
56− <div class="grow" id="park-text"></div>
31+ </div>
57− <button class="primary small" id="park"></button>
32+ <p id="result" class="muted" hidden></p>
58− </div>
33+ <p id="parked" class="muted"></p>
59−
34+
60− <div class="result" id="result-card" hidden>
35+ <h2>Groups in this window</h2>
61− <span class="grow" id="result"></span>
36+ <ul class="list" id="groups"></ul>
62− <button class="link" id="undo-organize" hidden>Undo</button>
37+
63− <button class="link" id="undo-sweep" hidden>Undo</button>
38+ <div class="row"><h2 class="grow">Archive</h2><button class="link" id="restore-all" hidden>Restore all</button></div>
64− </div>
39+ <ul class="list" id="archive"></ul>
65−
40+
66− <div class="card section">
41+ <div class="row" style="margin-top:12px"><span class="grow"></span><button class="link" id="options">Settings</button></div>
67− <div class="head"><h2 class="grow">Groups in this window</h2></div>
68− <ul class="list scroll" id="groups"></ul>
69− </div>
70−
71− <div class="card section" id="archive-card" hidden>
72− <div class="head"><h2 class="grow">Recently archived</h2><button class="link small" id="restore-all">Restore all</button></div>
73− <ul class="list scroll" id="archive"></ul>
74− </div>
75− </main>
76−
77− <footer>
78− <button class="quiet small" id="organize" title="Group loose tabs now instead of waiting">Organize now</button>
79− <button class="quiet small" id="tidy" title="Park tabs you haven't used in a while">Tidy now</button>
80− <span class="grow"></span>
81− <button class="quiet small" id="options">Settings</button>
82− </footer>
83 <script src="popup.js" type="module"></script>
42 <script src="popup.js" type="module"></script>
84</body>
43</body>
85</html>
44</html>
src/popup/popup.ts+77−87
@@ -1,13 +1,15 @@
1−import { notices, renderNotice, renderPill, type Health } from "../shared/health";
2import { ago, el, send } from "../shared/messaging";
1import { ago, el, send } from "../shared/messaging";
3
2
4const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
3const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
5let windowId: number | undefined;
4let windowId: number | undefined;
6−let resultText = "";
7−const open = new Set<number>(); // groups whose actions are expanded, kept across refreshes
8
5
9−interface Status extends Health {
6+interface Status {
7+ health: "ok" | "unknown" | "degraded";
8+ message: string;
9+ lastError?: { code: string; message: string };
10 lastPing?: { fmAvailable: boolean; schemasOk: boolean };
10 lastPing?: { fmAvailable: boolean; schemasOk: boolean };
11+ timeoutHint: boolean;
12+ inFlight: boolean;
11 tidyMode: string;
13 tidyMode: string;
12 tidyThreshold: number;
14 tidyThreshold: number;
13 tidyCandidates: number;
15 tidyCandidates: number;
@@ -15,95 +17,99 @@ interface Status extends Health {
15 canUndoSweep: boolean;
17 canUndoSweep: boolean;
16 canUndoOrganize: boolean;
18 canUndoOrganize: boolean;
17 archive: { url: string; title: string; favIconUrl?: string; groupTitle?: string; archivedAt: number }[];
19 archive: { url: string; title: string; favIconUrl?: string; groupTitle?: string; archivedAt: number }[];
18− groups: { id: number; title: string; color?: string; origin: string; managed: boolean; userNamed: boolean; keep: boolean; dirty: boolean; size: number }[];
20+ groups: { id: number; title: string; origin: string; managed: boolean; userNamed: boolean; keep: boolean; dirty: boolean; size: number }[];
19 extensionId: string;
21 extensionId: string;
20 manifestPath: string;
22 manifestPath: string;
21}
23}
22
24
23−const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
24−
25function renderStatus(s: Status): void {
25function renderStatus(s: Status): void {
26− renderPill($("status"), s);
26+ const dot = $("status-dot");
27−
27+ const text = $("status-text");
28− const list = notices(s);
28+ dot.className = "dot";
29− const box = $("notices");
29+ if (s.health === "degraded") {
30− box.replaceChildren(...list.map(renderNotice));
30+ dot.classList.add("bad");
31− box.hidden = list.length === 0;
31+ const code = s.lastError?.code;
32−
32+ text.textContent =
33− const tabs = s.groups.reduce((n, g) => n + g.size, 0);
33+ code === "HOST_NOT_FOUND" ? "Host not installed" :
34− const naming = s.groups.filter((g) => g.dirty && !g.userNamed).length;
34+ code === "MODEL_UNAVAILABLE" ? "Model unavailable" :
35− $("summary-title").textContent = s.groups.length ? `${plural(s.groups.length, "group")} · ${plural(tabs, "tab")}` : "Nothing to group yet";
35+ code === "LICENSE_REQUIRED" ? "Run sudo fm license" :
36− $("summary-sub").textContent = [
36+ code === "HOST_FORBIDDEN" || code === "FORBIDDEN_ORIGIN" ? "Host refuses this ID" :
37− s.groups.length ? "" : "Related tabs gather into named groups as you browse.",
37+ code === "SCHEMA_MISSING" ? "Schemas missing" : "Host unavailable";
38− naming ? `Naming ${plural(naming, "group")}…` : "",
38+ } else if (s.health === "ok") {
39− s.parkedCount ? `${plural(s.parkedCount, "tab")} parked` : "",
39+ dot.classList.add("ok");
40− ].filter(Boolean).join(" · ") || "Everything is in place.";
40+ text.textContent = s.inFlight ? "Naming…" : "Host ok";
41−
41+ } else {
42− const ask = s.tidyMode === "ask" && s.tidyCandidates >= s.tidyThreshold;
42+ dot.classList.add("warn");
43− $("park-card").hidden = !ask;
43+ text.textContent = "Not checked yet";
44− $("park-text").textContent = `${plural(s.tidyCandidates, "tab")} you haven't used in a while.`;
44+ }
45− $("park").textContent = "Park them";
45+ const notice = $("notice");
46+ notice.replaceChildren();
47+ const lines: (string | Node)[] = [];
48+ if (s.message) lines.push(s.message);
49+ if (s.timeoutHint) lines.push("Three timeouts in a row: consider raising the timeout in Settings.");
50+ if (s.lastError?.code === "HOST_NOT_FOUND") {
51+ lines.push(el("div", {}, el("button", { className: "link", textContent: "Copy manifest path", onclick: () => navigator.clipboard.writeText(s.manifestPath) })));
52+ }
53+ if (s.lastError?.code === "MODEL_UNAVAILABLE") {
54+ lines.push(el("div", {}, el("button", {
55+ className: "link",
56+ textContent: "Open Apple Intelligence settings",
57+ onclick: () => chrome.tabs.create({ url: "x-apple.systempreferences:com.apple.Siri-Settings.extension" }),
58+ })));
59+ }
60+ notice.hidden = lines.length === 0;
61+ notice.className = s.health === "degraded" ? "notice bad" : "notice";
62+ for (const l of lines) notice.append(typeof l === "string" ? el("div", { textContent: l }) : l);
46
63
64+ const park = $<HTMLButtonElement>("park");
65+ park.hidden = !(s.tidyMode === "ask" && s.tidyCandidates >= s.tidyThreshold);
66+ park.textContent = `Park ${s.tidyCandidates} tabs`;
47 $("undo-sweep").hidden = !s.canUndoSweep;
67 $("undo-sweep").hidden = !s.canUndoSweep;
48 $("undo-organize").hidden = !s.canUndoOrganize;
68 $("undo-organize").hidden = !s.canUndoOrganize;
49− const both = s.canUndoSweep && s.canUndoOrganize;
69+ $("parked").textContent = s.parkedCount ? `${s.parkedCount} tab${s.parkedCount === 1 ? "" : "s"} parked in this window` : "";
50− $("undo-organize").textContent = both ? "Undo organize" : "Undo";
51− $("undo-sweep").textContent = both ? "Undo tidy" : "Undo";
52− const fallback = s.canUndoOrganize ? "Organized a moment ago." : s.canUndoSweep ? "Tidied a moment ago." : "";
53− $("result").textContent = resultText || fallback;
54− $("result-card").hidden = !(resultText || fallback);
55
70
56 const groups = $("groups");
71 const groups = $("groups");
57 groups.replaceChildren();
72 groups.replaceChildren();
58− if (!s.groups.length) groups.append(el("li", { className: "empty", textContent: "Open a few tabs on one topic and they'll gather here." }));
73+ if (!s.groups.length) groups.append(el("li", { className: "muted", textContent: "No groups yet. Cmd-click a link to start one." }));
59 for (const g of s.groups) {
74 for (const g of s.groups) {
60 const act = (label: string, title: string, cmd: string, extra: Record<string, unknown> = {}) =>
75 const act = (label: string, title: string, cmd: string, extra: Record<string, unknown> = {}) =>
61− el("button", { className: "small", textContent: label, title, onclick: () => run(cmd, { groupId: g.id, ...extra }) });
76+ el("button", { textContent: label, title, onclick: () => run(cmd, { groupId: g.id, ...extra }) });
62− const tags = [
77+ const tags = [g.origin === "user" ? "yours" : g.origin, g.userNamed ? "fixed name" : g.dirty ? "renaming" : "", g.keep ? "kept" : ""].filter(Boolean).join(" · ");
63− g.origin === "user" ? "Your group" : "Made by Diagonal",
78+ groups.append(
64− g.userNamed ? "your title" : g.dirty ? "naming…" : "",
79+ el("li", {},
65− g.keep ? "never tidied" : "",
80+ el("div", { className: "row" },
66− ].filter(Boolean).join(" · ");
81+ el("span", { className: "grow ellipsis", textContent: g.title || "(untitled)" }),
67− const d = el("details", { className: "group", open: open.has(g.id) },
82+ el("span", { className: "muted count", textContent: String(g.size) })),
68− el("summary", {},
83+ el("div", { className: "row group-actions" },
69− el("span", { className: `swatch ${g.color ?? "grey"}` }),
84+ el("span", { className: "grow muted", textContent: tags }),
70− el("span", { className: "grow ellipsis", textContent: g.title || "Untitled group" }),
85+ act("Name now", "Name this group now", "groupNameNow"),
71− el("span", { className: "count num", textContent: String(g.size) }),
86+ act(g.keep ? "Unkeep" : "Keep", "Exclude from tidy", "groupKeep", { keep: !g.keep }),
72− el("span", { className: "chev" })),
87+ ...(g.userNamed ? [] : [act("Don't name", "Stop naming this group", "groupDontName")]),
73− el("div", { className: "more" },
74− el("div", { className: "tag", textContent: tags }),
75− el("div", { className: "acts" },
76− ...(g.userNamed ? [] : [act("Rename now", "Ask the model for a new name now", "groupNameNow")]),
77− act(g.keep ? "Allow tidying" : "Never tidy", "Keep these tabs out of tidy sweeps", "groupKeep", { keep: !g.keep }),
78− ...(g.userNamed ? [] : [act("Stop naming", "Leave this group's title alone", "groupDontName")]),
79 act("Ungroup", "Ungroup these tabs", "groupUngroup"))));
88 act("Ungroup", "Ungroup these tabs", "groupUngroup"))));
80− d.addEventListener("toggle", () => (d.open ? open.add(g.id) : open.delete(g.id)));
81− groups.append(el("li", {}, d));
82 }
89 }
83
90
84 const archive = $("archive");
91 const archive = $("archive");
85 archive.replaceChildren();
92 archive.replaceChildren();
86− $("archive-card").hidden = s.archive.length === 0;
93+ $("restore-all").hidden = s.archive.length === 0;
94+ if (!s.archive.length) archive.append(el("li", { className: "muted", textContent: "Nothing archived." }));
87 for (const a of s.archive) {
95 for (const a of s.archive) {
88 const icon = a.favIconUrl && /^(https?|data):/.test(a.favIconUrl) ? el("img", { className: "fav", src: a.favIconUrl, alt: "" }) : el("span", { className: "fav" });
96 const icon = a.favIconUrl && /^(https?|data):/.test(a.favIconUrl) ? el("img", { className: "fav", src: a.favIconUrl, alt: "" }) : el("span", { className: "fav" });
89 archive.append(
97 archive.append(
90− el("li", { className: "arch" },
98+ el("li", { className: "row" },
91 icon,
99 icon,
92 el("div", { className: "grow" },
100 el("div", { className: "grow" },
93− el("div", { className: "ellipsis", textContent: a.title || a.url, title: a.url }),
101+ el("div", { className: "ellipsis", textContent: a.title, title: a.url }),
94− el("div", { className: "sub ellipsis", textContent: [a.groupTitle, ago(a.archivedAt)].filter(Boolean).join(" · ") })),
102+ el("div", { className: "muted ellipsis", textContent: [a.groupTitle, ago(a.archivedAt)].filter(Boolean).join(" · ") })),
95− el("div", { className: "acts" },
103+ el("button", { className: "link", textContent: "Restore", onclick: () => run("restore", { archivedAt: a.archivedAt, url: a.url }) }),
96− el("button", { className: "link small", textContent: "Restore", onclick: () => run("restore", { archivedAt: a.archivedAt, url: a.url }) }),
104+ el("button", { className: "link", textContent: "Forget", onclick: () => run("forget", { archivedAt: a.archivedAt, url: a.url }) })));
97− el("button", { className: "quiet forget", textContent: "×", title: "Forget this tab", ariaLabel: "Forget", onclick: () => run("forget", { archivedAt: a.archivedAt, url: a.url }) }))));
98 }
105 }
99}
106}
100
107
101async function refresh(): Promise<void> {
108async function refresh(): Promise<void> {
102 try {
109 try {
103 renderStatus(await send<Status>("status", { windowId }));
110 renderStatus(await send<Status>("status", { windowId }));
104− } catch {
111+ } catch (e) {
105− renderPill($("status"), { health: "degraded", groupsSupported: true, message: "", timeoutHint: false, inFlight: false });
112+ $("status-text").textContent = "Worker not responding";
106− $("status-text").textContent = "Not responding";
107 }
113 }
108}
114}
109
115
@@ -118,9 +124,9 @@ async function run(cmd: string, extra: Record<string, unknown> = {}): Promise<un
118}
124}
119
125
120function showResult(text: string): void {
126function showResult(text: string): void {
121− resultText = text;
127+ const r = $("result");
122− $("result").textContent = text;
128+ r.textContent = text;
123− $("result-card").hidden = !text;
129+ r.hidden = !text;
124}
130}
125
131
126async function main(): Promise<void> {
132async function main(): Promise<void> {
@@ -132,32 +138,16 @@ async function main(): Promise<void> {
132 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
138 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
133 b.disabled = false;
139 b.disabled = false;
134 b.textContent = "Organize now";
140 b.textContent = "Organize now";
135− if (r) {
141+ if (r) showResult(`${r.grouped} tabs grouped into ${r.groups} new group${r.groups === 1 ? "" : "s"}, ${r.left} left${r.error ? ` (stopped: ${r.error})` : ""}.`);
136− showResult(r.grouped
137− ? `Grouped ${plural(r.grouped, "tab")} into ${plural(r.groups, "new group")}.${r.error ? ` Stopped early: ${r.error}` : ""}`
138− : r.error ? `Couldn't organize: ${r.error}` : "Nothing new to group.");
139− await refresh();
140− }
141 };
142 };
142 const tidy = async () => {
143 const tidy = async () => {
143 const r = (await run("tidyNow")) as { parked: number; archived: number } | undefined;
144 const r = (await run("tidyNow")) as { parked: number; archived: number } | undefined;
144− if (r) {
145+ if (r) showResult(`Parked ${r.parked}, archived ${r.archived}.`);
145− showResult(r.parked || r.archived
146− ? [r.parked ? `Parked ${plural(r.parked, "tab")}` : "", r.archived ? `archived ${plural(r.archived, "tab")}` : ""].filter(Boolean).join(", ") + "."
147− : "Nothing needed tidying.");
148− await refresh();
149− }
150 };
146 };
151 $("tidy").onclick = tidy;
147 $("tidy").onclick = tidy;
152 $("park").onclick = tidy;
148 $("park").onclick = tidy;
153− $("undo-sweep").onclick = async () => {
149+ $("undo-sweep").onclick = async () => showResult(`Restored ${await run("undoSweep")} tabs.`);
154− const n = (await run("undoSweep")) as number | undefined;
150+ $("undo-organize").onclick = async () => showResult(`Restored ${await run("undoOrganize")} tabs.`);
155− showResult(`Put back ${plural(n ?? 0, "tab")}.`);
156− };
157− $("undo-organize").onclick = async () => {
158− const n = (await run("undoOrganize")) as number | undefined;
159− showResult(`Put back ${plural(n ?? 0, "tab")}.`);
160− };
161 $("restore-all").onclick = () => run("restoreAll");
151 $("restore-all").onclick = () => run("restoreAll");
162 $("options").onclick = () => chrome.runtime.openOptionsPage();
152 $("options").onclick = () => chrome.runtime.openOptionsPage();
163 await refresh();
153 await refresh();
src/shared/health.tsdeleted−111
@@ -1,111 +0,0 @@
1−/** Turns the worker's host health into the words the popup and settings page show. */
2−import { el } from "./messaging";
3−
4−export interface Health {
5− health: "ok" | "unknown" | "degraded";
6− groupsSupported: boolean;
7− message: string;
8− lastError?: { code: string; message: string };
9− timeoutHint: boolean;
10− inFlight: boolean;
11−}
12−
13−export type Tone = "ok" | "warn" | "bad";
14−
15−export interface Notice {
16− tone: "warn" | "bad" | "info";
17− title: string;
18− body: string;
19− command?: string;
20− action?: { label: string; run: () => void };
21−}
22−
23−export function pill(s: Health): { tone: Tone; label: string; busy: boolean } {
24− if (s.health === "degraded") {
25− const code = s.lastError?.code;
26− const label =
27− code === "HOST_NOT_FOUND" ? "Setup needed" :
28− code === "LICENSE_REQUIRED" ? "Terms needed" :
29− code === "MODEL_UNAVAILABLE" ? "Model off" :
30− code === "HOST_FORBIDDEN" || code === "FORBIDDEN_ORIGIN" ? "Setup needed" :
31− code === "SCHEMA_MISSING" ? "Setup needed" : "Paused";
32− return { tone: "bad", label, busy: false };
33− }
34− if (s.health === "ok") return { tone: "ok", label: s.inFlight ? "Naming…" : "Ready", busy: s.inFlight };
35− return { tone: "warn", label: "Connecting…", busy: true };
36−}
37−
38−export function notices(s: Health): Notice[] {
39− const out: Notice[] = [];
40− if (!s.groupsSupported) {
41− out.push({ tone: "bad", title: "This browser can't group tabs", body: "It doesn't let extensions manage tab groups, so Diagonal has nothing to do here." });
42− }
43− if (s.health === "degraded") {
44− const e = s.lastError;
45− switch (e?.code) {
46− case "HOST_NOT_FOUND":
47− out.push({
48− tone: "bad", title: "Finish setting up Diagonal",
49− body: chrome.runtime.getManifest().update_url
50− ? "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal."
51− : "Diagonal's helper isn't registered with this browser yet. Installed with Homebrew? Run this in Terminal, then reopen Diagonal. Installed from a zip? Run: bash <the Diagonal folder>/install-host.command",
52− // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
53− command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
54− });
55− break;
56− case "LICENSE_REQUIRED":
57− out.push({
58− tone: "bad", title: "Accept Apple's model terms",
59− body: "Apple's fm tool needs its terms accepted once on this Mac. Run this in Terminal and Diagonal picks up on its own.",
60− command: "sudo fm license",
61− });
62− break;
63− case "MODEL_UNAVAILABLE":
64− out.push({
65− tone: "bad", title: "Apple Intelligence is off",
66− body: `Groups can't be named until the on-device model is available. ${e.message}`.trim(),
67− action: { label: "Open Apple Intelligence settings", run: () => void chrome.tabs.create({ url: "x-apple.systempreferences:com.apple.Siri-Settings.extension" }) },
68− });
69− break;
70− case "HOST_FORBIDDEN":
71− case "FORBIDDEN_ORIGIN":
72− out.push({ tone: "bad", title: "The helper doesn't recognize this extension", body: "Reinstalling registers it again.", command: "brew reinstall diagonal" });
73− break;
74− case "SCHEMA_MISSING":
75− out.push({
76− tone: "bad", title: "The model isn't set up yet",
77− body: `${e.message}. Diagonal keeps retrying on its own. This shows the full reason:`,
78− command: "~/.local/bin/diagonal-host --selftest",
79− });
80− break;
81− default:
82− out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
83− }
84− }
85− if (s.timeoutHint) {
86− 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." });
87− }
88− return out;
89−}
90−
91−export function renderNotice(n: Notice): HTMLElement {
92− const node = el("div", { className: `notice ${n.tone}` }, el("div", { className: "title", textContent: n.title }), el("p", { textContent: n.body }));
93− if (n.command) {
94− const cmd = n.command;
95− const copy = el("button", { className: "small", textContent: "Copy" });
96− copy.onclick = async () => {
97− await navigator.clipboard.writeText(cmd).catch(() => undefined);
98− copy.textContent = "Copied";
99− setTimeout(() => (copy.textContent = "Copy"), 1400);
100− };
101− node.append(el("div", { className: "cmd" }, el("code", { textContent: cmd }), copy));
102− }
103− if (n.action) node.append(el("div", {}, el("button", { className: "link", textContent: n.action.label, onclick: n.action.run })));
104− return node;
105−}
106−
107−export function renderPill(root: HTMLElement, s: Health): void {
108− const p = pill(s);
109− root.className = `pill ${p.tone}${p.busy ? " busy" : ""}`;
110− root.replaceChildren(el("span", { className: "dot" }), el("span", { id: "status-text", textContent: p.label }));
111−}
src/shared/label.ts+8−49
@@ -7,12 +7,9 @@ export const LABEL_MAX_CHARS = 28;
7export const LABEL_MIN_WORDS = 2;
7export const LABEL_MIN_WORDS = 2;
8export const LABEL_MAX_WORDS = 4;
8export const LABEL_MAX_WORDS = 4;
9
9
10−// Quotes go; an apostrophe between two letters ("Children's", "Rock'n'roll") is part of the word and stays.
10+const QUOTES = /["'“”‘’«»`]/g;
11−const QUOTES = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/gu;
11+const HAS_QUOTE = /["'“”‘’«»`]/;
12−const HAS_QUOTE = /(?<!\p{L})['’]|['’](?!\p{L})|["“”‘«»`]/u;
12+const TRAILING_PUNCT = /[\s.,;:!?…\-–—]+$/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"]);
16
13
17/** Repair what is repairable; return undefined when the label cannot be made valid. */
14/** Repair what is repairable; return undefined when the label cannot be made valid. */
18export function repairLabel(raw: unknown): string | undefined {
15export function repairLabel(raw: unknown): string | undefined {
@@ -20,50 +17,19 @@ export function repairLabel(raw: unknown): string | undefined {
20 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
17 let words = raw.replace(QUOTES, "").replace(/\s+/g, " ").trim().replace(TRAILING_PUNCT, "").split(" ").filter(Boolean);
21 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
18 words = words.filter((w) => !BANNED_WORDS.has(w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "")));
22 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
19 if (words.length > LABEL_MAX_WORDS) words = words.slice(0, LABEL_MAX_WORDS);
23− if (noSpace(words.join(" "))) {
20+ while (words.length > LABEL_MIN_WORDS && words.join(" ").length > LABEL_MAX_CHARS) words.pop();
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();
30 let label = words.join(" ").replace(TRAILING_PUNCT, "");
21 let label = words.join(" ").replace(TRAILING_PUNCT, "");
31 if (!label) return undefined;
22 if (!label) return undefined;
32− // "rust async" becomes "Rust async"; "iPhone tips" and "macOS updates" keep the brand's own casing.
23+ label = label[0].toLocaleUpperCase() + label.slice(1);
33− if (!/\p{Lu}/u.test(words[0].slice(1))) label = label[0].toLocaleUpperCase() + label.slice(1);
34 return isValidLabel(label) ? label : undefined;
24 return isValidLabel(label) ? label : undefined;
35}
25}
36
26
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−
60export function isValidLabel(label: string): boolean {
27export function isValidLabel(label: string): boolean {
61 const words = label.split(" ").filter(Boolean);
28 const words = label.split(" ").filter(Boolean);
62− const cjk = noSpace(label);
63 return (
29 return (
64− label.length >= (cjk ? CJK_MIN_CHARS : LABEL_MIN_CHARS) &&
30+ label.length >= LABEL_MIN_CHARS &&
65− (cjk ? widthOf(label) <= CJK_MAX_CHARS && label.length <= LABEL_MAX_CHARS : label.length <= LABEL_MAX_CHARS) &&
31+ label.length <= LABEL_MAX_CHARS &&
66− words.length >= (cjk ? 1 : LABEL_MIN_WORDS) &&
32+ words.length >= LABEL_MIN_WORDS &&
67 words.length <= LABEL_MAX_WORDS &&
33 words.length <= LABEL_MAX_WORDS &&
68 !HAS_QUOTE.test(label) &&
34 !HAS_QUOTE.test(label) &&
69 !TRAILING_PUNCT.test(label) &&
35 !TRAILING_PUNCT.test(label) &&
@@ -87,10 +53,3 @@ export function labelOf(title: string | undefined): string {
87
53
88export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
54export const sameLabel = (a: string | undefined, b: string | undefined): boolean =>
89 !!a && !!b && labelOf(a).trim().toLocaleLowerCase() === labelOf(b).trim().toLocaleLowerCase();
55 !!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();
src/shared/ui.css+40−130
@@ -1,144 +1,54 @@
1−/* Shared look for the popup and the settings page. Colours follow the icon: deep emerald glass. */
2:root {
1:root {
3− --bg: #f5f7f6;
2+ --bg: #ffffff;
4− --surface: #ffffff;
3+ --panel: #f6f7f9;
5− --surface-2: #eef2f0;
4+ --text: #1f2328;
6− --text: #15201b;
5+ --muted: #656d76;
7− --muted: #5d6b65;
6+ --border: #d8dee4;
8− --faint: #8a9690;
7+ --accent: #1a7f5a;
9− --border: #e0e6e3;
10− --accent: #127556;
11− --accent-hover: #0e6448;
12− --accent-soft: #e0f1ea;
13 --accent-text: #ffffff;
8 --accent-text: #ffffff;
14− --warn: #9a5a00;
9+ --warn: #b35900;
15− --warn-soft: #fcf1df;
10+ --bad: #cf222e;
16− --bad: #b8322a;
11+ --good: #1a7f37;
17− --bad-soft: #fcebe9;
12+ font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
18− --good: #1f8a58;
13+ color-scheme: light dark;
19− --shadow: 0 1px 2px rgb(16 40 30 / .06), 0 1px 1px rgb(16 40 30 / .04);
20− --radius: 10px;
21− --g-grey: #5f6368; --g-blue: #1a73e8; --g-red: #d93025; --g-yellow: #e8a100; --g-green: #1e8e3e;
22− --g-pink: #d01884; --g-purple: #a142f4; --g-cyan: #007b83; --g-orange: #f07a1a;
23− font: 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
24− color-scheme: light;
25− -webkit-font-smoothing: antialiased;
26}
14}
27@media (prefers-color-scheme: dark) {
15@media (prefers-color-scheme: dark) {
28 :root {
16 :root {
29− --bg: #0f1412;
17+ --bg: #1c1d1f;
30− --surface: #171e1b;
18+ --panel: #26282b;
31− --surface-2: #1f2824;
19+ --text: #e6e8ea;
32− --text: #e4ebe8;
20+ --muted: #9aa1a9;
33− --muted: #97a39e;
21+ --border: #3a3d41;
34− --faint: #6d7a74;
22+ --accent: #3fb883;
35− --border: #28322e;
23+ --accent-text: #0d1a14;
36− --accent: #52c99c;
24+ --warn: #e0a050;
37− --accent-hover: #6ad6ad;
25+ --bad: #ff7b72;
38− --accent-soft: #163429;
26+ --good: #56d364;
39− --accent-text: #06140e;
40− --warn: #e7ac5c;
41− --warn-soft: #2e2415;
42− --bad: #ff8d84;
43− --bad-soft: #341b19;
44− --good: #52c99c;
45− --shadow: none;
46− --g-grey: #bdc1c6; --g-blue: #8ab4f8; --g-red: #f28b82; --g-yellow: #fdd663; --g-green: #81c995;
47− --g-pink: #ff8bcb; --g-purple: #d7aefb; --g-cyan: #78d9ec; --g-orange: #fcad70;
48− color-scheme: dark;
49 }
27 }
50}
28}
51−
52* { box-sizing: border-box; }
29* { box-sizing: border-box; }
53−[hidden] { display: none !important; }
54body { margin: 0; background: var(--bg); color: var(--text); }
30body { margin: 0; background: var(--bg); color: var(--text); }
55−h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: -.01em; }
31+h1 { font-size: 15px; margin: 0; }
56−h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0; }
32+h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 16px 0 6px; }
57−p { margin: 0; }
58−a { color: var(--accent); }
59−:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
60−
61button {
33button {
62− font: inherit; font-weight: 500; color: var(--text); background: var(--surface);
34+ font: inherit; border: 1px solid var(--border); background: var(--panel); color: var(--text);
63− border: 1px solid var(--border); border-radius: 8px; padding: 5px 11px; cursor: pointer;
35+ border-radius: 6px; padding: 5px 10px; cursor: pointer;
64− transition: background .12s, border-color .12s, color .12s;
65}
36}
66−button:hover { background: var(--surface-2); }
37+button:hover { border-color: var(--muted); }
67−button:disabled { opacity: .55; cursor: default; }
38+button.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; font-weight: 600; }
68−button.primary { background: var(--accent); border-color: transparent; color: var(--accent-text); }
39+button.link { border: 0; background: none; color: var(--accent); padding: 2px 4px; }
69−button.primary:hover { background: var(--accent-hover); }
40+button:disabled { opacity: .5; cursor: default; }
70−button.link { border: 0; background: none; color: var(--accent); padding: 2px 4px; border-radius: 6px; }
71−button.link:hover { background: var(--accent-soft); }
72−button.quiet { border-color: transparent; background: none; color: var(--muted); }
73−button.quiet:hover { background: var(--surface-2); color: var(--text); }
74−button.small { font-size: 12px; padding: 3px 8px; border-radius: 6px; }
75−
76.muted { color: var(--muted); }
41.muted { color: var(--muted); }
77−.faint { color: var(--faint); }
78.row { display: flex; gap: 8px; align-items: center; }
42.row { display: flex; gap: 8px; align-items: center; }
79−.row > .grow, .grow { flex: 1; min-width: 0; }
43+.row > .grow { flex: 1; min-width: 0; }
80.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81−.num { font-variant-numeric: tabular-nums; }
45+.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--panel); }
82−code, .mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; font-size: 12px; }
46+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
83−img.fav { width: 16px; height: 16px; flex: none; border-radius: 3px; }
47+.dot.ok { background: var(--good); } .dot.bad { background: var(--bad); } .dot.warn { background: var(--warn); }
84−span.fav { display: inline-block; width: 16px; height: 16px; flex: none; border-radius: 4px; background: var(--surface-2); }
48+.notice { background: var(--panel); border-left: 3px solid var(--warn); padding: 8px 10px; border-radius: 4px; margin-top: 8px; word-break: break-word; }
85−.logo { flex: none; border-radius: 22%; }
49+.notice.bad { border-left-color: var(--bad); }
86−
50+.list { list-style: none; margin: 0; padding: 0; }
87−.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
51+.list li { padding: 6px 0; border-bottom: 1px solid var(--border); }
88−
52+.list li:last-child { border-bottom: 0; }
89−/* Status pill: a dot and a short word. */
53+code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
90−.pill {
54+img.fav { width: 16px; height: 16px; flex: none; }
91− display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500;
92− padding: 3px 10px 3px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); white-space: nowrap;
93−}
94−.pill.ok { background: var(--accent-soft); color: var(--accent); }
95−.pill.bad { background: var(--bad-soft); color: var(--bad); }
96−.pill.warn { background: var(--warn-soft); color: var(--warn); }
97−.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
98−.pill.busy .dot { animation: pulse 1.2s ease-in-out infinite; }
99−@keyframes pulse { 50% { opacity: .3; } }
100−@media (prefers-reduced-motion: reduce) { .pill.busy .dot { animation: none; } }
101−
102−/* Notices: a title, a sentence, and at most one command to copy. */
103−.notice { border-radius: var(--radius); padding: 10px 12px; background: var(--warn-soft); color: var(--text); display: grid; gap: 6px; }
104−.notice.bad { background: var(--bad-soft); }
105−.notice.info { background: var(--surface-2); }
106−.notice .title { font-weight: 600; }
107−.notice.bad .title { color: var(--bad); }
108−.notice:not(.bad):not(.info) .title { color: var(--warn); }
109−.notice p { color: var(--muted); word-break: break-word; }
110−.cmd {
111− display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--border);
112− border-radius: 7px; padding: 4px 4px 4px 10px;
113−}
114−.cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; }
115−
116−/* Chrome tab-group colours. */
117−.swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; background: var(--g-grey); }
118−.swatch.blue { background: var(--g-blue); } .swatch.red { background: var(--g-red); } .swatch.yellow { background: var(--g-yellow); }
119−.swatch.green { background: var(--g-green); } .swatch.pink { background: var(--g-pink); } .swatch.purple { background: var(--g-purple); }
120−.swatch.cyan { background: var(--g-cyan); } .swatch.orange { background: var(--g-orange); }
121−
122−/* A switch drawn over a real checkbox, so labels and keyboard behave as usual. */
123−input.switch {
124− appearance: none; -webkit-appearance: none; margin: 0; flex: none; cursor: pointer;
125− width: 34px; height: 20px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s;
126−}
127−input.switch::after {
128− content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
129− background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .25); transition: transform .15s;
130−}
131−input.switch:checked { background: var(--accent); }
132−input.switch:checked::after { transform: translateX(14px); }
133−@media (prefers-color-scheme: dark) { input.switch:not(:checked) { background: #3a4540; } }
134−
135−.toast {
136− position: fixed; left: 50%; bottom: 16px; transform: translate(-50%, 8px); opacity: 0; pointer-events: none;
137− background: var(--text); color: var(--bg); padding: 6px 14px; border-radius: 999px; font-size: 12px; font-weight: 500;
138− transition: opacity .2s, transform .2s;
139−}
140−.toast.show { opacity: 1; transform: translate(-50%, 0); }
141−
142−/* Disclosure chevron drawn with borders so it turns cleanly. */
143−.chev { width: 6px; height: 6px; flex: none; border-right: 1.5px solid var(--faint); border-bottom: 1.5px solid var(--faint); transform: rotate(-45deg); transition: transform .15s; margin: 0 4px 0 2px; }
144−details[open] > summary .chev { transform: rotate(45deg); }
src/shared/url.ts+1−1
@@ -1,6 +1,6 @@
1import { colorForKey, type GroupColor } from "./colors";
1import { colorForKey, type GroupColor } from "./colors";
2
2
3−const INTERNAL_PREFIXES = ["brave:", "chrome:", "chrome-extension:", "chrome-search:", "chrome-untrusted:", "devtools:", "edge:", "vivaldi:", "opera:", "file:", "about:", "view-source:", "data:", "javascript:", "blob:"];
3+const INTERNAL_PREFIXES = ["brave:", "chrome:", "chrome-extension:", "chrome-search:", "chrome-untrusted:", "devtools:", "edge:", "file:", "about:", "view-source:", "data:", "javascript:", "blob:"];
4
4
5/** True for pages that never feed grouping data or prompts (section 11). */
5/** True for pages that never feed grouping data or prompts (section 11). */
6export function isInternalUrl(url: string | undefined): boolean {
6export function isInternalUrl(url: string | undefined): boolean {
test/auto.test.ts+2−90
@@ -15,7 +15,6 @@ let state: State;
15let calls: OrganizePayload[];
15let calls: OrganizePayload[];
16let replies: HostReply<unknown>[];
16let replies: HostReply<unknown>[];
17let fallbacks: number[];
17let fallbacks: number[];
18−let respond: ((payload: OrganizePayload, opts?: object) => HostReply<unknown> | undefined) | undefined;
19
18
20const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
19const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
21 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
20 id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/page`, title: `Tab ${id}`, status: "complete", pinned: false, ...over,
@@ -33,9 +32,9 @@ function setup(tabs: LiveTab[]): AutoOrganizer {
33 settings: () => makeSettings(),
32 settings: () => makeSettings(),
34 commit: () => undefined,
33 commit: () => undefined,
35 now: () => 1_000_000,
34 now: () => 1_000_000,
36− host: async <T,>(_op: string, payload: object, opts?: object) => {
35+ host: async <T,>(_op: string, payload: object) => {
37 calls.push(JSON.parse(JSON.stringify(payload)));
36 calls.push(JSON.parse(JSON.stringify(payload)));
38− return (respond?.(payload as OrganizePayload, opts) ?? replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
37+ return (replies.shift() ?? { ok: true, result: { groups: [], leftovers: [] } }) as HostReply<T>;
39 },
38 },
40 naming: { touch: () => undefined } as unknown as Runtime["naming"],
39 naming: { touch: () => undefined } as unknown as Runtime["naming"],
41 refreshBadge: () => undefined,
40 refreshBadge: () => undefined,
@@ -49,7 +48,6 @@ beforeEach(() => {
49 calls = [];
48 calls = [];
50 replies = [];
49 replies = [];
51 fallbacks = [];
50 fallbacks = [];
52− respond = undefined;
53 nextGroup = 100;
51 nextGroup = 100;
54 (globalThis as any).chrome = {
52 (globalThis as any).chrome = {
55 tabs: {
53 tabs: {
@@ -94,7 +92,6 @@ describe("auto-organize", () => {
94 await auto.run(1);
92 await auto.run(1);
95 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
93 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
96 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
94 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97− expect(calls[0].siblingTitles).toEqual(["Rust"]);
98 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
95 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
99 });
96 });
100
97
@@ -112,12 +109,6 @@ describe("auto-organize", () => {
112 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
109 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 5", "Tab 6"]);
113 });
110 });
114
111
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−
121 it("one loose tab with nothing to join waits for company", async () => {
112 it("one loose tab with nothing to join waits for company", async () => {
122 const auto = setup([liveTab(1)]);
113 const auto = setup([liveTab(1)]);
123 await auto.run(1);
114 await auto.run(1);
@@ -152,83 +143,4 @@ describe("auto-organize", () => {
152 vi.useRealTimers();
143 vi.useRealTimers();
153 }
144 }
154 });
145 });
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− });
234});
146});
test/e2e/selfupdate.mjsdeleted−89
@@ -1,89 +0,0 @@
1−// End-to-end check of self-update: loads dist/ the way "Load unpacked" does (CDP Extensions.loadUnpacked,
2−// so the browser treats it as a development install), swaps the folder for a newer version the way the
3−// Homebrew cask does, opens the popup, and expects the worker to reload itself into the new version.
4−// node test/e2e/selfupdate.mjs (after npm run build)
5−import { spawn } from "node:child_process";
6−import { cpSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7−import { tmpdir } from "node:os";
8−import { dirname, join } from "node:path";
9−import { fileURLToPath } from "node:url";
10−
11−const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
12−const tmp = mkdtempSync(join(tmpdir(), "diagonal-selfupdate-"));
13−const ext = join(tmp, "extension");
14−cpSync(join(root, "dist"), ext, { recursive: true });
15−const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16−
17−const chrome = spawn(process.env.CHROMIUM_PATH || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome", [
18− "--headless=new", "--no-sandbox", "--remote-debugging-pipe", "--enable-unsafe-extension-debugging",
19− `--user-data-dir=${join(tmp, "profile")}`, "--no-first-run", "about:blank",
20−], { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] });
21−const [, , , toChrome, fromChrome] = chrome.stdio;
22−let buf = "";
23−let nextId = 1;
24−const pending = new Map();
25−fromChrome.on("data", (d) => {
26− buf += d.toString();
27− for (let i; (i = buf.indexOf("\0")) >= 0; buf = buf.slice(i + 1)) {
28− const msg = JSON.parse(buf.slice(0, i));
29− pending.get(msg.id)?.(msg);
30− pending.delete(msg.id);
31− }
32−});
33−const send = (method, params = {}, sessionId) =>
34− new Promise((resolve) => {
35− const id = nextId++;
36− pending.set(id, resolve);
37− toChrome.write(JSON.stringify({ id, method, params, sessionId }) + "\0");
38− });
39−
40−async function inPage(path, expression, keepOpen = false) {
41− const { result: t } = await send("Target.createTarget", { url: `chrome-extension://${id}/${path}` });
42− await sleep(1000);
43− const { result: a } = await send("Target.attachToTarget", { targetId: t.targetId, flatten: true });
44− if (!a) return undefined; // the page already went away (the extension reloaded under it)
45− const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, a.sessionId);
46− if (!keepOpen) await send("Target.closeTarget", { targetId: t.targetId });
47− return r.result?.result?.value;
48−}
49−const version = () => inPage("options.html", "chrome.runtime.getManifest().version");
50−
51−let failed = false;
52−const check = (name, ok, detail = "") => {
53− failed ||= !ok;
54− console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
55−};
56−
57−await sleep(1500);
58−const loaded = await send("Extensions.loadUnpacked", { path: ext });
59−const id = loaded.result?.id;
60−check("loads unpacked", !!id, JSON.stringify(loaded.error ?? ""));
61−const before = await version();
62−check("runs the version it was loaded with", before === JSON.parse(readFileSync(join(root, "dist/manifest.json"), "utf8")).version, before);
63−
64−// The cask stages the new release next to the old one and renames it into place.
65−const newer = "2999.1.1.1";
66−const staged = `${ext}.new`;
67−cpSync(ext, staged, { recursive: true });
68−const manifest = JSON.parse(readFileSync(join(staged, "manifest.json"), "utf8"));
69−manifest.version = newer;
70−writeFileSync(join(staged, "manifest.json"), JSON.stringify(manifest, null, 2));
71−renameSync(ext, `${ext}.old`);
72−renameSync(staged, ext);
73−
74−// Opening the popup (or settings) asks the worker for status, which checks disk.
75−await inPage("popup.html", "1", true);
76−let after;
77−for (let i = 0; i < 20 && after !== newer; i++) {
78− await sleep(500);
79− after = await version().catch(() => undefined);
80−}
81−check("reloads itself into the newer version on disk", after === newer, `${before} -> ${after}`);
82−
83−// Same version on disk again: nothing to do, and no reload loop.
84−await inPage("popup.html", "1");
85−await sleep(2000);
86−check("stays on it afterwards", (await version()) === newer);
87−
88−chrome.kill();
89−process.exit(failed ? 1 : 0);
test/e2e/smoke-fm.py+12−9
@@ -19,7 +19,7 @@ def topic_of(text):
19
19
20
20
21def items(prompt):
21def items(prompt):
22− body = prompt.split("Tabs (index | title | address | what the page says):", 1)[-1]
22+ body = prompt.split("Tabs (index | title | address | description):", 1)[-1]
23 out = []
23 out = []
24 for line in body.strip().splitlines():
24 for line in body.strip().splitlines():
25 m = re.match(r"^(\d+) \| (.*)$", line)
25 m = re.match(r"^(\d+) \| (.*)$", line)
@@ -39,19 +39,22 @@ def main():
39 print(json.dumps({"schema": argv[1:]}))
39 print(json.dumps({"schema": argv[1:]}))
40 return
40 return
41 if argv[0] == "respond":
41 if argv[0] == "respond":
42− # Like the real fm: rules arrive with -i, the tab list on stdin.
42+ prompt = argv[-1]
43− instructions = argv[argv.index("-i") + 1] if "-i" in argv else ""
43+ if prompt.startswith("You name"):
44− prompt = sys.stdin.read()
45− if instructions.startswith("You name"):
46 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
44 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
47 print(json.dumps({"title": found[0], "emoji": found[1]}))
45 print(json.dumps({"title": found[0], "emoji": found[1]}))
48 return
46 return
49− if instructions.startswith("You label"):
47+ if prompt.startswith("You sort"):
50− tabs = []
48+ buckets = {}
49+ leftovers = []
51 for i, rest in items(prompt):
50 for i, rest in items(prompt):
52 t = topic_of(rest)
51 t = topic_of(rest)
53− tabs.append({"index": i, "topic": t[0].split()[0] if t else f"Other {i}"})
52+ if t:
54− print(json.dumps({"tabs": tabs}))
53+ buckets.setdefault(t, []).append(i)
54+ else:
55+ leftovers.append(i)
56+ groups = [{"title": t[0], "emoji": t[1], "color": t[2], "existing": -1, "members": m} for t, m in buckets.items()]
57+ print(json.dumps({"groups": groups, "leftovers": leftovers}))
55 return
58 return
56 sys.stderr.write("unexpected fm call\n")
59 sys.stderr.write("unexpected fm call\n")
57 sys.exit(1)
60 sys.exit(1)
test/e2e/smoke.mjs+1−28
@@ -47,8 +47,6 @@ const pages = {
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
47 "/pasta": ["Pasta recipe", "A weeknight pasta recipe.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
48 "/cook": ["How to cook rice", "Cook rice perfectly.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
49 "/misc": ["Something else", "Unrelated page.", ""],
50− "/flight": ["Flights to Lisbon", "Cheap flights to Lisbon.", ""],
51− "/risotto": ["Mushroom risotto recipe", "A creamy risotto recipe.", ""],
52};
50};
53const server = createServer((req, res) => {
51const server = createServer((req, res) => {
54 const p = pages[req.url.split("?")[0]];
52 const p = pages[req.url.split("?")[0]];
@@ -188,37 +186,12 @@ try {
188 const pasta = await tabIn(win2, "/pasta");
186 const pasta = await tabIn(win2, "/pasta");
189 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 }));
187 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 }));
190
188
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−
216 // 7. Popup renders the healthy state.
189 // 7. Popup renders the healthy state.
217 await popup.setViewportSize({ width: 360, height: 560 });
190 await popup.setViewportSize({ width: 360, height: 560 });
218 await popup.reload();
191 await popup.reload();
219 await sleep(800);
192 await sleep(800);
220 const statusText = await popup.textContent("#status-text");
193 const statusText = await popup.textContent("#status-text");
221− check("popup shows the host as healthy", statusText === "Ready" || statusText === "Naming…", statusText);
194+ check("popup shows the host as healthy", statusText === "Host ok" || statusText === "Naming…", statusText);
222 await popup.screenshot({ path: join(shotDir, "popup.png") });
195 await popup.screenshot({ path: join(shotDir, "popup.png") });
223
196
224 const options = await context.newPage();
197 const options = await context.newPage();
test/engine.test.ts+1−185
@@ -167,10 +167,7 @@ describe("dirty triggers", () => {
167
167
168 it("title change and path change mark the group dirty", () => {
168 it("title change and path change mark the group dirty", () => {
169 expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, title: "New" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
169 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([
170+ expect(step(base(), { type: "tabUpdated", tab: tab(1, { groupId: 10, url: "https://example.com/other" }) }, ctx()).actions).toEqual([{ type: "dirty", groupId: 10 }]);
171− { type: "checkFit" },
172− { type: "dirty", groupId: 10 },
173− ]);
174 });
171 });
175
172
176 it("reload, fragment and query changes do not", () => {
173 it("reload, fragment and query changes do not", () => {
@@ -240,14 +237,6 @@ describe("auto-organize triggers", () => {
240 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
237 expect(actions).toContainEqual({ type: "loose", windowId: 1 });
241 });
238 });
242
239
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−
251 it("opening a parked tab takes it out of Parked", () => {
240 it("opening a parked tab takes it out of Parked", () => {
252 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
241 const s = stateWith([tab(1, { groupId: 20 }), tab(2, { groupId: 20 })], [{ id: 20, origin: "tidy" }]);
253 s.tabs[1].parkedFrom = "Rust async";
242 s.tabs[1].parkedFrom = "Rust async";
@@ -263,176 +252,3 @@ describe("auto-organize triggers", () => {
263 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
252 expect(state.tabs[1].lastActivatedAt).toBe(5_000_000);
264 });
253 });
265});
254});
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.tsdeleted−18
@@ -1,18 +0,0 @@
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+7−85
@@ -20,14 +20,14 @@ describe("membership hash", () => {
20});
20});
21
21
22describe("large group sampling", () => {
22describe("large group sampling", () => {
23− it("sends the 16 most recent, the first 16 and the last 16, in strip order, capped at 48", () => {
23+ it("sends the 8 most recent, the first 8 and the last 8, in strip order, capped at 24", () => {
24− const ms = Array.from({ length: 80 }, (_, i) => member(i, { lastActive: i === 40 || i === 41 ? 1000 + i : i }));
24+ const ms = Array.from({ length: 40 }, (_, i) => member(i, { lastActive: i === 20 || i === 21 ? 1000 + i : i }));
25 const picked = sampleMembers(ms);
25 const picked = sampleMembers(ms);
26− expect(picked.length).toBeLessThanOrEqual(48);
26+ expect(picked.length).toBeLessThanOrEqual(24);
27 const ids = picked.map((m) => m.id);
27 const ids = picked.map((m) => m.id);
28 expect(ids).toEqual([...ids].sort((a, b) => a - b));
28 expect(ids).toEqual([...ids].sort((a, b) => a - b));
29− for (const id of [0, 15, 64, 79, 40, 41]) expect(ids).toContain(id);
29+ for (const id of [0, 7, 32, 39, 20, 21]) expect(ids).toContain(id);
30− expect(ids).not.toContain(30);
30+ expect(ids).not.toContain(10);
31 });
31 });
32
32
33 it("leaves small groups whole", () => {
33 it("leaves small groups whole", () => {
@@ -50,10 +50,6 @@ describe("evolving-name policy", () => {
50 it("same label is never rewritten", () => {
50 it("same label is never rewritten", () => {
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
51 expect(shouldApply(g(), "rust async runtimes", ["a", "b", "c"])).toBe(false);
52 });
52 });
53− it("a member that only moved within its page is not a membership change", () => {
54− const g2 = newGroupRecord(1, 1, "opener", "blue", { title: "Rust async runtimes", lastNamedAt: 1, memberUrls: ["https://a.com/x", "https://b.com/y"] });
55− expect(shouldApply(g2, "Tokio and async-std", ["https://a.com/x?page=2", "https://b.com/y#top"])).toBe(false);
56− });
57 it("user-named groups are skipped", () => {
53 it("user-named groups are skipped", () => {
58 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
54 expect(shouldApply({ ...g(), userNamed: true }, "Anything else", ["x"])).toBe(false);
59 });
55 });
@@ -155,33 +151,15 @@ describe("naming queue", () => {
155 expect(state.groups[7].dirty).toBe(false);
151 expect(state.groups[7].dirty).toBe(false);
156 });
152 });
157
153
158− it("waits while a member is still loading", async () => {
154+ it("waits while a member is still loading or the group has one tab", async () => {
159 members = [member(1), member(2, { status: "loading" })];
155 members = [member(1), member(2, { status: "loading" })];
160 naming.touch(7);
156 naming.touch(7);
161 await settle();
157 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 () => {
167 members = [member(1)];
158 members = [member(1)];
168 naming.touch(7);
159 naming.touch(7);
169 await settle();
160 await settle();
170 expect(calls).toHaveLength(0);
161 expect(calls).toHaveLength(0);
171− expect(state.groups[7].dirty).toBe(false);
162+ expect(state.groups[7].dirty).toBe(true);
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);
185 });
163 });
186
164
187 it("new-tab pages do not count as members", async () => {
165 it("new-tab pages do not count as members", async () => {
@@ -220,54 +198,6 @@ describe("naming queue", () => {
220 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
198 expect(state.groups[7].nextAttemptAt).toBe(now + 24 * 3_600_000);
221 });
199 });
222
200
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−
271 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
201 it("failures back off 30 s, 2 min, 10 min, then hourly; the title is left alone", async () => {
272 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
202 const fail: HostReply<NameResult> = { ok: false, error: { code: "FM_ERROR", message: "boom" } };
273 replies.push(fail);
203 replies.push(fail);
@@ -299,14 +229,6 @@ describe("naming queue", () => {
299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
229 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300 });
230 });
301
231
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−
310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
232 it("a group renamed by hand while the model runs keeps the user's title", async () => {
311 const n = new Naming({
233 const n = new Naming({
312 ...(naming as any).d,
234 ...(naming as any).d,
test/organize.test.ts+3−27
@@ -8,9 +8,9 @@ const t = (n: number, over: { title?: string; description?: string } = {}) => ({
8});
8});
9
9
10describe("batching", () => {
10describe("batching", () => {
11− it("caps batches at 36 items", () => {
11+ it("caps batches at 18 items", () => {
12− const b = makeBatches(Array.from({ length: 80 }, (_, i) => t(i)));
12+ const b = makeBatches(Array.from({ length: 60 }, (_, i) => t(i)));
13− expect(b.map((x) => x.length)).toEqual([36, 36, 8]);
13+ expect(b.map((x) => x.length)).toEqual([18, 18, 18, 6]);
14 });
14 });
15
15
16 it("also respects the character budget", () => {
16 it("also respects the character budget", () => {
@@ -21,13 +21,6 @@ describe("batching", () => {
21 expect(b.flat()).toHaveLength(30);
21 expect(b.flat()).toHaveLength(30);
22 });
22 });
23
23
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−
31 it("maxGroups = min(8, ceil(items / 3))", () => {
24 it("maxGroups = min(8, ceil(items / 3))", () => {
32 expect(maxGroupsFor(18)).toBe(6);
25 expect(maxGroupsFor(18)).toBe(6);
33 expect(maxGroupsFor(4)).toBe(2);
26 expect(maxGroupsFor(4)).toBe(2);
@@ -107,23 +100,6 @@ describe("existing groups", () => {
107 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
100 expect(list[0]).toEqual({ g: 0, title: "Trip 0", samples: ["Hotel"] });
108 });
101 });
109
102
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−
127 it("groups created by batch n come first for batch n+1", () => {
103 it("groups created by batch n come first for batch n+1", () => {
128 const s = emptyState();
104 const s = emptyState();
129 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
105 for (let i = 0; i < 15; i++) s.groups[i] = newGroupRecord(i, 1, "organize", "red", { stripTitle: `G ${i}` });
test/selfupdate.test.tsdeleted−46
@@ -1,46 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import { compareVersions, readDisk, shouldReload } from "../src/background/selfupdate";
3−
4−describe("compareVersions", () => {
5− it("compares numerically, ignoring leading zeros", () => {
6− expect(compareVersions("2026.09.24.0512", "2026.9.24.512")).toBe(0);
7− expect(compareVersions("2026.09.24.0512", "2026.09.23.2039")).toBeGreaterThan(0);
8− expect(compareVersions("0.1.0", "2026.09.23.2039")).toBeLessThan(0);
9− expect(compareVersions("1.2", "1.2.0.1")).toBeLessThan(0);
10− });
11−});
12−
13−describe("shouldReload", () => {
14− const base = { running: "2026.09.23.2039", filesReady: true };
15− it("reloads when a newer version is on disk", () => {
16− expect(shouldReload({ ...base, onDisk: "2026.09.24.0512" })).toBe(true);
17− });
18− it("stays put for the same or an older version", () => {
19− expect(shouldReload({ ...base, onDisk: "2026.09.23.2039" })).toBe(false);
20− expect(shouldReload({ ...base, onDisk: "2026.09.22.1000" })).toBe(false);
21− });
22− it("waits while the folder is mid-copy or unreadable", () => {
23− expect(shouldReload({ ...base, onDisk: "2026.09.24.0512", filesReady: false })).toBe(false);
24− expect(shouldReload({ ...base, onDisk: undefined })).toBe(false);
25− expect(shouldReload({ ...base, onDisk: "garbage" })).toBe(false);
26− });
27− it("tries each on-disk version once, so a failed reload cannot loop", () => {
28− expect(shouldReload({ ...base, onDisk: "2026.09.24.0512", lastTried: "2026.09.24.0512" })).toBe(false);
29− expect(shouldReload({ ...base, onDisk: "2026.09.25.0100", lastTried: "2026.09.24.0512" })).toBe(true);
30− });
31−});
32−
33−describe("readDisk", () => {
34− const url = (p: string) => `chrome-extension://x/${p}`;
35− const res = (body: string, ok = true) => ({ ok, json: async () => JSON.parse(body), text: async () => body }) as Response;
36− it("reads the manifest version when both files are there", async () => {
37− const f = (async (u: string) => (u.endsWith("manifest.json") ? res('{"version":"2026.09.24.0512"}') : res("code"))) as typeof fetch;
38− expect(await readDisk(f, url)).toEqual({ onDisk: "2026.09.24.0512", filesReady: true });
39− });
40− it("is not ready when a file is missing or the manifest is half-written", async () => {
41− const missing = (async (u: string) => (u.endsWith("manifest.json") ? res("{}") : res("", false))) as typeof fetch;
42− expect((await readDisk(missing, url)).filesReady).toBe(false);
43− const half = (async (u: string) => (u.endsWith("manifest.json") ? res('{"vers') : res("code"))) as typeof fetch;
44− expect((await readDisk(half, url)).filesReady).toBe(false);
45− });
46−});
test/settings.test.tsdeleted−8
@@ -1,8 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import { withDefaults } from "../src/background/settings";
3−
4−describe("withDefaults", () => {
5− it("reads a stored Private Cloud Compute choice as the on-device model, the only one fm has", () => {
6− expect(withDefaults({ model: "pcc" }).model).toBe("system");
7− });
8−});
test/sweep.test.tsdeleted−181
@@ -1,181 +0,0 @@
1−import { afterEach, beforeEach, describe, expect, it } from "vitest";
2−import type { Runtime } from "../src/background/runtime";
3−import { emptyState, newGroupRecord, type State } from "../src/background/state";
4−import { HOUR, runSweep, undoSweep } from "../src/background/tidy";
5−import { settings as makeSettings } from "./helpers";
6−
7−/** The tidy sweep against a small in-memory strip with Chromium's group rules: an emptied group is removed. */
8−
9−type LiveTab = {
10− id: number; windowId: number; index: number; groupId: number; url: string; title: string;
11− pinned: boolean; active: boolean; discarded: boolean; autoDiscardable: boolean; lastAccessed: number;
12−};
13−type LiveGroup = { id: number; windowId: number; title: string; color: string; collapsed: boolean };
14−
15−const T0 = 10_000 * HOUR;
16−let now: number;
17−let live: LiveTab[];
18−let groups: Map<number, LiveGroup>;
19−let nextGroup: number;
20−let state: State;
21−let discarded: number[];
22−
23−const liveTab = (id: number, over: Partial<LiveTab> = {}): LiveTab => ({
24− id, windowId: 1, index: id, groupId: -1, url: `https://site${id}.com/p`, title: `Tab ${id}`,
25− pinned: false, active: false, discarded: false, autoDiscardable: true, lastAccessed: T0 - 60 * HOUR, ...over,
26−});
27−
28−function remember(t: LiveTab): void {
29− state.tabs[t.id] = { id: t.id, windowId: t.windowId, groupId: t.groupId, index: t.index, url: t.url, title: t.title, pinned: t.pinned, createdAt: 0, lastActivatedAt: t.lastAccessed };
30−}
31−
32−const dropEmpty = () => {
33− for (const id of [...groups.keys()]) if (!live.some((t) => t.groupId === id)) groups.delete(id);
34−};
35−
36−const rt: Runtime = {
37− state: () => state,
38− settings: () => makeSettings(),
39− commit: () => undefined,
40− now: () => now,
41− host: async () => ({ ok: false, error: { code: "FM_ERROR", message: "not used" } }) as never,
42− naming: { touch: () => undefined } as unknown as Runtime["naming"],
43− refreshBadge: () => undefined,
44− log: () => undefined,
45−};
46−
47−beforeEach(() => {
48− now = T0;
49− state = emptyState();
50− groups = new Map();
51− nextGroup = 500;
52− discarded = [];
53− (globalThis as any).chrome = {
54− tabs: {
55− query: async (q: { windowId?: number; groupId?: number }) =>
56− live.filter((t) => (q.windowId === undefined || t.windowId === q.windowId) && (q.groupId === undefined || t.groupId === q.groupId)).map((t) => ({ ...t })),
57− get: async (id: number) => ({ ...live.find((t) => t.id === id)! }),
58− group: async ({ tabIds, groupId, createProperties }: { tabIds: number[]; groupId?: number; createProperties?: { windowId: number } }) => {
59− let id = groupId;
60− if (id === undefined) {
61− id = nextGroup++;
62− groups.set(id, { id, windowId: createProperties?.windowId ?? 1, title: "", color: "grey", collapsed: false });
63− }
64− for (const t of live) if (tabIds.includes(t.id)) t.groupId = id;
65− dropEmpty();
66− return id;
67− },
68− ungroup: async (ids: number[]) => {
69− for (const t of live) if (ids.includes(t.id)) t.groupId = -1;
70− dropEmpty();
71− },
72− move: async () => undefined,
73− discard: async (id: number) => {
74− discarded.push(id);
75− live.find((t) => t.id === id)!.discarded = true;
76− },
77− remove: async (ids: number[]) => {
78− live = live.filter((t) => !ids.includes(t.id));
79− dropEmpty();
80− },
81− },
82− tabGroups: {
83− get: async (id: number) => {
84− const g = groups.get(id);
85− if (!g) throw new Error(`No group with id: ${id}.`);
86− return { ...g };
87− },
88− query: async (q: { windowId?: number }) => [...groups.values()].filter((g) => q.windowId === undefined || g.windowId === q.windowId).map((g) => ({ ...g })),
89− update: async (id: number, props: Partial<LiveGroup>) => Object.assign(groups.get(id)!, props),
90− move: async () => undefined,
91− },
92− };
93−});
94−
95−afterEach(() => {
96− delete (globalThis as any).chrome;
97−});
98−
99−function strip(tabs: LiveTab[], made: LiveGroup[] = []): void {
100− live = tabs;
101− for (const g of made) groups.set(g.id, g);
102− for (const t of tabs) remember(t);
103−}
104−
105−const parkedGroupId = () => Object.values(state.groups).find((g) => g.origin === "tidy")?.id;
106−
107−describe("tidy sweep", () => {
108− it("parks a stale tab and closes it only after it has sat in Parked for the archive time", async () => {
109− strip([liveTab(1), liveTab(2, { active: true })]);
110− expect(await runSweep(rt)).toMatchObject({ parked: 1, archived: 0 });
111− expect(live.find((t) => t.id === 1)!.groupId).toBe(parkedGroupId());
112− expect(state.tabs[1].parkedAt).toBe(T0);
113−
114− now = T0 + 47 * HOUR;
115− expect(await runSweep(rt)).toMatchObject({ archived: 0 });
116− now = T0 + 48 * HOUR;
117− expect(await runSweep(rt)).toMatchObject({ archived: 1 });
118− expect(state.archive.map((a) => a.url)).toEqual(["https://site1.com/p"]);
119− });
120−
121− it("two sweeps at once archive a tab once", async () => {
122− strip([liveTab(1, { groupId: 9 }), liveTab(2, { active: true })], [{ id: 9, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
123− state.groups[9] = newGroupRecord(9, 1, "tidy", "grey", { userNamed: true, stripTitle: "💤 Parked" });
124− state.tabs[1].parkedAt = T0 - 49 * HOUR;
125− await Promise.all([runSweep(rt), runSweep(rt, true)]);
126− expect(state.archive).toHaveLength(1);
127− });
128−
129− it("a Parked group whose record an older version lost is Parked again", async () => {
130− strip([liveTab(1, { groupId: 50 }), liveTab(2)], [{ id: 50, windowId: 1, title: "💤 Parked", color: "grey", collapsed: true }]);
131− state.groups[50] = newGroupRecord(50, 1, "user", "grey", { userNamed: true, stripTitle: "💤 Parked" });
132− await runSweep(rt);
133− expect(live.find((t) => t.id === 2)!.groupId).toBe(50);
134− expect(groups.size).toBe(1);
135− expect(state.groups[50]).toMatchObject({ origin: "tidy", managed: true });
136− });
137−
138− it("does not discard a tab marked never to be discarded", async () => {
139− strip([liveTab(1), liveTab(2, { autoDiscardable: false }), liveTab(3, { active: true })]);
140− await runSweep(rt);
141− expect(discarded).toEqual([1]);
142− });
143−});
144−
145−describe("undo tidy", () => {
146− it("brings back a group that parking emptied, with its title", async () => {
147− strip(
148− [liveTab(1, { active: true }), liveTab(2, { groupId: 7 }), liveTab(3, { groupId: 7 })],
149− [{ id: 7, windowId: 1, title: "🦀 Rust async", color: "orange", collapsed: false }],
150− );
151− state.groups[7] = newGroupRecord(7, 1, "organize", "orange", { title: "Rust async", emoji: "🦀", stripTitle: "🦀 Rust async" });
152− await runSweep(rt);
153− expect(groups.has(7)).toBe(false);
154−
155− expect(await undoSweep(rt)).toBe(2);
156− const back = live.find((t) => t.id === 2)!.groupId;
157− expect(back).not.toBe(-1);
158− expect(live.find((t) => t.id === 3)!.groupId).toBe(back);
159− expect(groups.get(back)).toMatchObject({ title: "🦀 Rust async", color: "orange" });
160− expect(state.groups[back]).toMatchObject({ origin: "organize", title: "Rust async", userNamed: false });
161− expect(state.tabs[2].parkedAt).toBeUndefined();
162− });
163−
164− it("puts loose tabs back as the worker's own move, so they are not taken for tabs you pulled out", async () => {
165− strip([liveTab(1, { active: true }), liveTab(4)]);
166− await runSweep(rt);
167− await undoSweep(rt);
168− expect(live.find((t) => t.id === 4)!.groupId).toBe(-1);
169− expect(state.ownUngroups[4]).toBeDefined();
170− });
171−
172− it("covers every sweep in the last hour, not only the latest", async () => {
173− strip([liveTab(1, { active: true }), liveTab(2), liveTab(3, { lastAccessed: T0 - 23.5 * HOUR })]);
174− state.tabs[3].lastActivatedAt = T0 - 23.5 * HOUR;
175− await runSweep(rt, true);
176− now = T0 + HOUR / 2;
177− await runSweep(rt);
178− expect(live.filter((t) => t.groupId !== -1).map((t) => t.id)).toEqual([2, 3]);
179− expect(await undoSweep(rt)).toBe(2);
180− });
181−});
test/tidy.test.ts+3−34
@@ -34,19 +34,6 @@ describe("park eligibility", () => {
34 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
34 expect(isParkCandidate(tab, s, cfg, NOW)).toBe(expected);
35 });
35 });
36
36
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−
50 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
37 it("user groups are eligible when 'Tidy inside my own groups' is on", () => {
51 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
38 expect(isParkCandidate(tt({ groupId: 10 }), s, settings({ tidyUserGroups: true }), NOW)).toBe(true);
52 });
39 });
@@ -64,32 +51,14 @@ describe("park eligibility", () => {
64});
51});
65
52
66describe("archive", () => {
53describe("archive", () => {
67− const record = (id: number, over: object = {}) => ({ id, windowId: 1, groupId: 12, index: 0, url: "", title: "", pinned: false, createdAt: 0, lastActivatedAt: NOW - 60 * HOUR, ...over });
54+ it("parked tabs idle past archiveAfter are archived; 0 means never", () => {
68−
69− it("parked tabs that sat in Parked past archiveAfter are archived; 0 means never", () => {
70 const s = emptyState();
55 const s = emptyState();
71 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
56 s.groups[12] = newGroupRecord(12, 1, "tidy", "grey");
72− s.tabs[1] = record(1, { parkedAt: NOW - 49 * HOUR });
57+ const parked = tt({ groupId: 12, lastAccessed: NOW - 49 * HOUR });
73− const parked = tt({ groupId: 12, lastAccessed: NOW - 60 * HOUR });
58+ expect(archiveCandidates([parked, tt()], s, settings(), NOW)).toEqual([parked]);
74− expect(archiveCandidates([parked, tt({ id: 2 })], s, settings(), NOW)).toEqual([parked]);
75 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
59 expect(archiveCandidates([parked], s, settings({ archiveAfterHours: 0 }), NOW)).toEqual([]);
76 });
60 });
77
61
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−
93 it("is newest first and capped, oldest dropped", () => {
62 it("is newest first and capped, oldest dropped", () => {
94 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
63 const entry = (n: number): ArchivedTab => ({ url: `u${n}`, title: `t${n}`, archivedAt: n, lastActivatedAt: 0 });
95 const existing = [3, 2, 1].map(entry);
64 const existing = [3, 2, 1].map(entry);
test/url.test.ts+13−20
@@ -1,8 +1,7 @@
1import { describe, expect, it } from "vitest";
1import { describe, expect, it } from "vitest";
2−import { isValidLabel, labelOf, repairLabel, safeEmoji, titleKey } from "../src/shared/label";
2+import { isValidLabel, labelOf, repairLabel, safeEmoji } from "../src/shared/label";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
3import { colorFor, hostname, isInternalUrl, promptAddress, promptUrl, provisionalTitle, registrableDomain, trimText } from "../src/shared/url";
4import { withDefaults } from "../src/background/settings";
4import { withDefaults } from "../src/background/settings";
5−import labelCases from "../host/tests/golden/labels.json";
6
5
7describe("url helpers", () => {
6describe("url helpers", () => {
8 it("strips www.", () => {
7 it("strips www.", () => {
@@ -46,21 +45,24 @@ describe("url helpers", () => {
46 });
45 });
47});
46});
48
47
49−// The same cases run against the host's validate.repair_label, so the two sides agree on every label.
50−const LABEL_CASES = labelCases as [string, string | null][];
51−
52describe("labels", () => {
48describe("labels", () => {
53− it.each(LABEL_CASES)("repairs %s", (raw, fixed) => {
49+ it.each([
54− expect(repairLabel(raw) ?? null).toBe(fixed);
50+ ['"Rust async runtimes."', "Rust async runtimes"],
51+ ["rust async runtimes", "Rust async runtimes"],
52+ ["Rust tabs and docs", "Rust and docs"],
53+ ["One two three four five six", "One two three four"],
54+ ["Machine learning research papers reading list", "Machine learning research"],
55+ ])("repairs %s", (raw, fixed) => {
56+ expect(repairLabel(raw)).toBe(fixed);
57+ });
58+
59+ it.each(["Tabs", "Misc stuff", "", "x", "Supercalifragilisticexpialidocious extraordinarily"])("rejects %s", (raw) => {
60+ expect(repairLabel(raw)).toBeUndefined();
55 });
61 });
56
62
57 it("validates", () => {
63 it("validates", () => {
58 expect(isValidLabel("Lisbon trip")).toBe(true);
64 expect(isValidLabel("Lisbon trip")).toBe(true);
59 expect(isValidLabel("Lisbon")).toBe(false);
65 expect(isValidLabel("Lisbon")).toBe(false);
60− expect(isValidLabel("日本旅行")).toBe(true);
61− expect(isValidLabel("京都の旅行計画")).toBe(true);
62− expect(isValidLabel("日")).toBe(false);
63− expect(repairLabel("日本旅行。")).toBe("日本旅行");
64 });
66 });
65
67
66 it("emoji outside the list fall back; FE0F is optional", () => {
68 it("emoji outside the list fall back; FE0F is optional", () => {
@@ -68,15 +70,6 @@ describe("labels", () => {
68 expect(safeEmoji("🦄")).toBe("🧭");
70 expect(safeEmoji("🦄")).toBe("🧭");
69 });
71 });
70
72
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−
80 it("labelOf strips our emoji prefix", () => {
73 it("labelOf strips our emoji prefix", () => {
81 expect(labelOf("🦀 Rust async")).toBe("Rust async");
74 expect(labelOf("🦀 Rust async")).toBe("Rust async");
82 expect(labelOf("Plain title")).toBe("Plain title");
75 expect(labelOf("Plain title")).toBe("Plain title");
tsconfig.json−1
@@ -9,7 +9,6 @@
9 "noEmit": true,
9 "noEmit": true,
10 "skipLibCheck": true,
10 "skipLibCheck": true,
11 "isolatedModules": true,
11 "isolatedModules": true,
12− "resolveJsonModule": true,
13 "noFallthroughCasesInSwitch": true,
12 "noFallthroughCasesInSwitch": true,
14 "forceConsistentCasingInFileNames": true
13 "forceConsistentCasingInFileNames": true
15 },
14 },