experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(cmd,tag-actions): restore #tag #tag→search shortcut + render toggles in tiles world

Two regressions surfaced 2026-04-26:

1. Tag-action toggles never rendered on item cards in tags / groups /
search / pagestream. Root causes: (a) consumer manifests were missing
`tag-actions:*` from their pubsub topic allowlists, so the strict tile
gate dropped the `tag-actions:get-all` round-trip; (b) a subscribe-
before-publish race where consumer tiles published before the resident
tag-actions tile had finished its async `loadSettings()`. Fixed by
adding the 5 topics to each consumer manifest and broadcasting
`tag-actions:get-all:response` once at the end of tag-actions init.

2. Typing `#tagA #tagB` + Enter in the cmd panel was a silent no-op.
The TYPING + ENTER transitions only handled `isURL` and
`userCommittedToCommand`; the all-hashtag fallback that used to route
to the search view was lost during the cmd panel rework. Added an
`isAllHashtags` guard and transition that invokes the existing
`openSearch` action (`peek://search/home.html?q=...`).

New regression specs: `tests/desktop/tag-actions-toggles.spec.ts` (4/0)
and `tests/desktop/cmd-hashtag-search.spec.ts` (1/0). Unit tests 593/0.

+335 -11
+7
CHANGELOG.md
··· 15 15 16 16 ## 2026-04-20 17 17 18 + Desktop - tag-action toggles + cmd panel hashtag shortcut 19 + - [x] Fix tag-action toggles never rendering on item cards in tags / groups / search / pagestream — add `tag-actions:*` (5 topics) to each consumer's `pubsub.topics` allowlist so the strict tile gate stops dropping the `tag-actions:get-all` round-trip 20 + - [x] Fix cold-start race in `features/tag-actions/home.js`: subscribe-before-publish was reachable when consumer tiles published their first `tag-actions:get-all` before the resident tag-actions tile had finished its `loadSettings()` await; tag-actions now broadcasts `tag-actions:get-all:response` once at end of init so any pre-existing subscriber catches up without a re-request 21 + - [x] New `tests/desktop/tag-actions-toggles.spec.ts` — full pipeline (toggle renders + click swaps `todo` → `done`) on tags/home, gate round-trip on groups/pagestream, manifest assertion for search 22 + - [x] Fix cmd panel `#tagA #tagB` + Enter regression — TYPING + ENTER had only `isURL` and `userCommittedToCommand` transitions, so all-hashtag input was a silent no-op; add `isAllHashtags` guard + transition that runs the existing `openSearch` action (opens `peek://search/home.html?q=...`) 23 + - [x] New `tests/desktop/cmd-hashtag-search.spec.ts` regression coverage 24 + 18 25 Desktop - v1 removal complete 19 26 - [x] Phase 3.7f — migrate darkMode, theme directory mgmt, sync, profiles, adblocker namespaces from legacy `ipcMain.handle` to strict `tile:*` handlers (trustedBuiltin-gated, token-validated) 20 27 - [x] Phase 3.7g — migrate `window-devtools`, `get-focused-visible-window-id`, `pubsub-stats` to strict `tile:*` handlers; delete legacy un-gated channels
+6
app/cmd/panel.js
··· 2620 2620 machine.getState() === States.CHAIN_MODE 2621 2621 ); 2622 2622 2623 + // All tokens are `#name` (no inner whitespace, no `#`-only): treat as 2624 + // a tag-search shortcut. Routes to the `search` view filtered by tags. 2625 + const tokens = trimmedText.split(/\s+/).filter(Boolean); 2626 + const allHashtags = tokens.length > 0 && tokens.every(t => /^#[^\s#]+$/.test(t)); 2627 + 2623 2628 machine.dispatch(Events.ENTER, { 2624 2629 value: commandInput.value, 2625 2630 isURL: urlResult.valid, 2626 2631 url: urlResult.url, 2632 + allHashtags, 2627 2633 committed, 2628 2634 commandName: name, 2629 2635 typed: commandInput.value,
+17 -4
app/cmd/state-machine.js
··· 148 148 isURL: (payload) => { 149 149 return payload?.isURL === true; 150 150 }, 151 + isAllHashtags: (payload) => { 152 + return payload?.allHashtags === true; 153 + }, 151 154 userCommittedToCommand: (payload) => { 152 155 return payload?.committed === true; 153 156 }, ··· 307 310 actions: ['openURL', 'recordFrecency', 'clearInput', 'shutdown'], 308 311 }, 309 312 { 313 + // All-hashtag input ("#todo #today") opens the search view filtered 314 + // by those tags. The narrow shape (every token is `#name`) is an 315 + // explicit user intent to query items by tag, distinct from the 316 + // generic "type random text → web search" fallback that was 317 + // intentionally removed. 318 + guard: 'isAllHashtags', 319 + target: States.CLOSING, 320 + actions: ['openSearch', 'clearInput', 'shutdown'], 321 + }, 322 + { 310 323 guard: 'userCommittedToCommand', 311 324 target: States.EXECUTING, 312 325 actions: ['recordFrecency', 'exitParamMode', 'executeCommand', 'clearInput', 'updateGhostText', 'updateResults'], 313 326 }, 314 - // No URL, no committed command → no-op (do NOT fall back to search; 315 - // typing random text and pressing Enter should not silently route to 316 - // a web search — the user invokes search explicitly via the `search` 317 - // command if they want one). 327 + // No URL, no all-hashtag query, no committed command → no-op 328 + // (do NOT fall back to web search; typing random text and pressing 329 + // Enter should not silently route to a web search — the user invokes 330 + // `search` explicitly if they want one). 318 331 ], 319 332 [Events.ESCAPE]: [ 320 333 { guard: 'textNonEmpty', target: States.IDLE, actions: ['clearInput', 'clearMatches', 'updateGhostText', 'updateResults'] },
+9 -2
docs/feed.xml
··· 4 4 <title>Peek Changelog</title> 5 5 <link>https://tangled.org/burrito.space/peek</link> 6 6 <description>Recent changes to Peek</description> 7 - <lastBuildDate>Sun, 26 Apr 2026 11:07:00 GMT</lastBuildDate> 7 + <lastBuildDate>Sun, 26 Apr 2026 11:07:34 GMT</lastBuildDate> 8 8 <generator>changelog-to-rss.js</generator> 9 9 <atom:link href="https://tangled.org/burrito.space/peek/raw/main/docs/feed.xml" rel="self" type="application/rss+xml"/> 10 10 <item> ··· 12 12 <link>https://tangled.org/burrito.space/peek/blob/main/CHANGELOG.md#2026-04-20</link> 13 13 <guid isPermaLink="false">https://tangled.org/burrito.space/peek#2026-04-20</guid> 14 14 <pubDate>Mon, 20 Apr 2026 12:00:00 GMT</pubDate> 15 - <description>Desktop - v1 removal complete 15 + <description>Desktop - tag-action toggles + cmd panel hashtag shortcut 16 + - Fix tag-action toggles never rendering on item cards in tags / groups / search / pagestream — add tag-actions:* (5 topics) to each consumer&apos;s pubsub.topics allowlist so the strict tile gate stops dropping the tag-actions:get-all round-trip 17 + - Fix cold-start race in features/tag-actions/home.js: subscribe-before-publish was reachable when consumer tiles published their first tag-actions:get-all before the resident tag-actions tile had finished its loadSettings() await; tag-actions now broadcasts tag-actions:get-all:response once at end of init so any pre-existing subscriber catches up without a re-request 18 + - New tests/desktop/tag-actions-toggles.spec.ts — full pipeline (toggle renders + click swaps todo → done) on tags/home, gate round-trip on groups/pagestream, manifest assertion for search 19 + - Fix cmd panel #tagA #tagB + Enter regression — TYPING + ENTER had only isURL and userCommittedToCommand transitions, so all-hashtag input was a silent no-op; add isAllHashtags guard + transition that runs the existing openSearch action (opens peek://search/home.html?q=...) 20 + - New tests/desktop/cmd-hashtag-search.spec.ts regression coverage 21 + 22 + Desktop - v1 removal complete 16 23 - Phase 3.7f — migrate darkMode, theme directory mgmt, sync, profiles, adblocker namespaces from legacy ipcMain.handle to strict tile:* handlers (trustedBuiltin-gated, token-validated) 17 24 - Phase 3.7g — migrate window-devtools, get-focused-visible-window-id, pubsub-stats to strict tile:* handlers; delete legacy un-gated channels 18 25 - Phase 3.7h — collapse all 13 api.window. capability-fallback branches in tile-preload.cts; delete the 13 legacy window- ipcMain.handle registrations (window-open, window-close, window-hide, window-show, window-focus, window-exists, window-list, window-center, window-center-all, window-maximize, window-fullscreen, window-set-ignore-mouse-events, window-set-overlay-focus-target)
+2 -1
docs/tasks.md
··· 21 21 - [ ] **Page host window jumps to wrong position after switching primary monitor.** Repro: connect external monitor, set it primary; open page host (cmd+L); disconnect/swap so the laptop becomes primary; cmd+L again — the whole window shifts to a different (off-screen / wrong-display) position. Likely cause: `computeWindowBounds`/canvas positioning caches screen bounds at first compute or reads stale `screen.getPrimaryDisplay()`/`getDisplayNearestPoint` results across display topology changes. Fix path: subscribe to Electron's `screen` `display-added`/`display-removed`/`display-metrics-changed` events and recompute the page-host window's normal+maximized bounds against current displays before show; clamp restored positions to a visible display. Surfaced 2026-04-24. 22 22 - [ ] **Proton Pass extension doesn't autofill.** Still broken — doesn't show up in form fields, no autofill suggestions. Likely a content-script injection or messaging API gap in the webview/canvas setup. Reproduce: install Proton Pass extension, navigate to any login page, observe no inline autofill UI. (2026-04-17: agent triaged, 5 hypotheses in agent-ae11e03d worktree — extension gitignored so couldn't repro.) 23 23 24 - - [ ] **Tag-action toggles never render in the tiles world.** Surfaced 2026-04-26 after fixing the `api.settings.get('data')` storage bug in `tag-actions/home.js` — even with valid pair data in the datastore, no toggle affordances appear on item cards in tags/groups/search/pagestream. Confirmed via direct user testing. Tag-actions toggles were "completely untested in tiles world" per the user. Fix is multi-step: 24 + - [x] **Tag-action toggles never render in the tiles world.** Surfaced 2026-04-26 after fixing the `api.settings.get('data')` storage bug in `tag-actions/home.js` — even with valid pair data in the datastore, no toggle affordances appear on item cards in tags/groups/search/pagestream. Confirmed via direct user testing. Tag-actions toggles were "completely untested in tiles world" per the user. Fix is multi-step: 25 25 26 26 **Phase 1 — pubsub topic allowlists.** Consumer features (`features/{tags,groups,search,pagestream}/manifest.json`) call `api.publish('tag-actions:get-all', {})` and subscribe to `:get-all:response` / `:create:response` / `:update:response` / `:delete:response` via `app/lib/tag-action-affordances.js:createActionRulesCache`. None of the 4 consumer manifests declare `tag-actions:*` in their `pubsub.topics` allowlist, so the publish is rejected (silently or with a `gate:rejected` event per `tile-ipc-gate.ts`). Add the 5 topics (`tag-actions:get-all`, `:get-all:response`, `:create:response`, `:update:response`, `:delete:response`) to each consumer's allowlist. Confirm by running packaged with `DEBUG=1`, opening tags/home and searching for `gate:rejected.*tag-actions` in stderr. 27 27 ··· 65 65 66 66 Keep short — for recent context only. Prune after a few weeks. 67 67 68 + - 2026-04-26 Tag-action toggles fixed (6-phase plan): added `tag-actions:*` to tags/groups/search/pagestream pubsub allowlists; added proactive `tag-actions:get-all:response` broadcast in `features/tag-actions/home.js:init` to defeat consumer cold-start race; new `tests/desktop/tag-actions-toggles.spec.ts` (4/0). Search runtime round-trip is asserted statically (manifest topic check) due to an unrelated `search-home` workspace-key collapse blocking fresh test windows. 68 69 - 2026-04-24 v1 removal complete: 36 commits stacked off main, every renderer routes through `tile-preload.cts` + strict `tile:*` IPC, manifestVersion 3 canonical, `extensions` SQLite table dropped, `extensionPaths` → `tilePaths`, `ext:*` startup topics → `feature:*`. Playwright 223/0, unit 2277/0. 69 70 - 2026-04-23 Pubsub message-passing state machine landed — 8-phase conversion (`docs/pubsub-state-machine.md`). P4 added bgWindow-ready latch + private lifecycle IPC; P5 deleted `cmd:request-registers` replay machinery. Resolved root cause of "only hello-world commands visible in cmd panel". Unit 2284/0, Playwright 208/0/11-skipped. 70 71 - 2026-04-23 Cmd-panel repeat invocation tests + 4 bugs fixed: panel state leak on keepLive reopen (private `tile:lifecycle:visible` IPC), `tile:window:close` now routes through `closeOrHideWindow`, per-invocation `cmd:execute:X:result` topic.
+6 -1
features/groups/manifest.json
··· 30 30 "sync:pull-completed", 31 31 "ext:ready", 32 32 "ext:groups:shutdown", 33 - "app:shutdown" 33 + "app:shutdown", 34 + "tag-actions:get-all", 35 + "tag-actions:get-all:response", 36 + "tag-actions:create:response", 37 + "tag-actions:update:response", 38 + "tag-actions:delete:response" 34 39 ] 35 40 }, 36 41 "datastore": {
+6 -1
features/pagestream/manifest.json
··· 37 37 "item:deleted", 38 38 "tag:item-added", 39 39 "tag:item-removed", 40 - "sync:pull-completed" 40 + "sync:pull-completed", 41 + "tag-actions:get-all", 42 + "tag-actions:get-all:response", 43 + "tag-actions:create:response", 44 + "tag-actions:update:response", 45 + "tag-actions:delete:response" 41 46 ] 42 47 }, 43 48 "datastore": {
+6 -1
features/search/manifest.json
··· 35 35 "item:deleted", 36 36 "tag:item-added", 37 37 "tag:item-removed", 38 - "editor:open" 38 + "editor:open", 39 + "tag-actions:get-all", 40 + "tag-actions:get-all:response", 41 + "tag-actions:create:response", 42 + "tag-actions:update:response", 43 + "tag-actions:delete:response" 39 44 ] 40 45 }, 41 46 "datastore": {
+7
features/tag-actions/home.js
··· 404 404 405 405 render(); 406 406 407 + // Proactive broadcast: consumers' first :get-all request may arrive before our 408 + // subscribe; pubsub doesn't buffer, so the cache would stay empty forever. 409 + api.pubsub.publish('tag-actions:get-all:response', { 410 + success: true, 411 + data: currentActions 412 + }); 413 + 407 414 debug && console.log('[tag-actions] initialized with', currentPairs.length, 'pairs'); 408 415 }; 409 416
+6 -1
features/tags/manifest.json
··· 34 34 "entities:extracted", 35 35 "ext:ready", 36 36 "ext:tags:shutdown", 37 - "app:shutdown" 37 + "app:shutdown", 38 + "tag-actions:get-all", 39 + "tag-actions:get-all:response", 40 + "tag-actions:create:response", 41 + "tag-actions:update:response", 42 + "tag-actions:delete:response" 38 43 ] 39 44 }, 40 45 "datastore": {
+62
tests/desktop/cmd-hashtag-search.spec.ts
··· 1 + /** 2 + * Regression coverage: typing `#tagA #tagB` in the cmd panel and pressing 3 + * Enter routes to the search view filtered by those tags. This was a daily 4 + * shortcut that broke during the cmd-panel rework when the all-hashtags 5 + * fallback was removed alongside the (intentional) web-search fallback. 6 + * 7 + * The fix wires TYPING + ENTER + isAllHashtags → openSearch. 8 + */ 9 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 10 + import { Page } from '@playwright/test'; 11 + import { createPerDescribeApp } from '../helpers/test-app'; 12 + import { waitForPanelCommandsLoaded } from '../helpers/window-utils'; 13 + 14 + test.describe('cmd hashtag → search @desktop', () => { 15 + let app: DesktopApp; 16 + let bgWindow: Page; 17 + 18 + test.beforeAll(async () => { 19 + ({ app, bgWindow } = await createPerDescribeApp('cmd-hashtag-search')); 20 + }); 21 + 22 + test.afterAll(async () => { 23 + if (app) await app.close(); 24 + }); 25 + 26 + test('typing #todo #today + Enter opens search/home with q param', async () => { 27 + // Open cmd panel 28 + const openResult = await bgWindow.evaluate(async () => { 29 + return await (window as any).app.window.open('peek://cmd/panel.html', { 30 + modal: true, width: 600, height: 400, frame: false, 31 + transparent: true, alwaysOnTop: true, center: true, 32 + }); 33 + }); 34 + expect(openResult.success).toBe(true); 35 + 36 + const cmdWindow = await app.getWindow('cmd/panel.html', 5000); 37 + await cmdWindow.waitForSelector('input', { timeout: 5000 }); 38 + await waitForPanelCommandsLoaded(cmdWindow); 39 + 40 + await cmdWindow.fill('input', '#todo #today'); 41 + await cmdWindow.keyboard.press('Enter'); 42 + 43 + // Search/home should open with q=#todo #today. 44 + // Use regex to avoid substring match against `peek://websearch/home.html`. 45 + const searchWindow = await app.getWindow(/peek:\/\/search\/home\.html/, 10000); 46 + expect(searchWindow).toBeTruthy(); 47 + await searchWindow.waitForLoadState('domcontentloaded'); 48 + 49 + const url = searchWindow.url(); 50 + const params = new URLSearchParams(new URL(url).search); 51 + expect(params.get('q')).toBe('#todo #today'); 52 + 53 + // Cleanup any windows 54 + if (openResult.id) { 55 + try { 56 + await bgWindow.evaluate(async (id: number) => { 57 + return await (window as any).app.window.close(id); 58 + }, openResult.id); 59 + } catch { /* may already be closed */ } 60 + } 61 + }); 62 + });
+201
tests/desktop/tag-actions-toggles.spec.ts
··· 1 + /** 2 + * Tag Action Toggles — render + click coverage across consumer tiles. 3 + * 4 + * Regression coverage for the 2026-04-26 bug where the toggle affordance 5 + * silently never rendered on item cards because consumer manifests 6 + * (tags/groups/search/pagestream) didn't list `tag-actions:*` in their 7 + * pubsub topic allowlist, and because the cold-start subscribe-before-publish 8 + * race could leave the consumer rules cache empty. 9 + * 10 + * Tests: 11 + * 1. tags/home — full pipeline: toggle renders on a todo-tagged card, 12 + * clicking it swaps `todo` → `done`. 13 + * 2-4. groups/search/pagestream — gate-permits round-trip: each consumer 14 + * window can publish `tag-actions:get-all` and receive a non-empty 15 + * action rules response. 16 + */ 17 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 18 + import { Page } from '@playwright/test'; 19 + import { createPerDescribeApp } from '../helpers/test-app'; 20 + 21 + test.describe('Tag Action Toggles @desktop', () => { 22 + let app: DesktopApp; 23 + let bgWindow: Page; 24 + 25 + test.beforeAll(async () => { 26 + ({ app, bgWindow } = await createPerDescribeApp('tag-action-toggles')); 27 + }); 28 + 29 + test.afterAll(async () => { 30 + if (app) await app.close(); 31 + }); 32 + 33 + /** 34 + * In a consumer window, subscribe to tag-actions:get-all:response, publish 35 + * tag-actions:get-all, and resolve with the response payload. Validates 36 + * (a) the consumer manifest grants the topics (gate would reject otherwise) 37 + * and (b) tag-actions/home is registered as a responder. 38 + */ 39 + async function fetchActionRules(window: Page): Promise<{ success: boolean; data: any }> { 40 + return await window.evaluate(async () => { 41 + const api = (window as any).app; 42 + return await new Promise((resolve) => { 43 + const timeout = setTimeout(() => resolve({ success: false, data: null }), 5000); 44 + const unsub = api.pubsub.subscribe('tag-actions:get-all:response', (msg: any) => { 45 + clearTimeout(timeout); 46 + if (typeof unsub === 'function') unsub(); 47 + resolve({ success: !!msg.success, data: msg.data }); 48 + }); 49 + api.pubsub.publish('tag-actions:get-all', {}); 50 + }); 51 + }); 52 + } 53 + 54 + test('toggle renders on tags/home and click swaps todo → done', async () => { 55 + const ts = Date.now(); 56 + const testUri = `https://tag-actions-toggle-test-${ts}.example.com/`; 57 + 58 + // Create the test item tagged `todo` (tag-actions default active tag). 59 + const setup = await bgWindow.evaluate(async (uri: string) => { 60 + const api = (window as any).app; 61 + const todo = await api.datastore.getOrCreateTag('todo'); 62 + const done = await api.datastore.getOrCreateTag('done'); 63 + const item = await api.datastore.addItem('url', { 64 + content: uri, 65 + metadata: JSON.stringify({ title: 'tag-actions toggle test' }) 66 + }); 67 + await api.datastore.tagItem(item.data.id, todo.data.tag.id); 68 + return { 69 + itemId: item.data.id, 70 + todoTagId: todo.data.tag.id, 71 + doneTagId: done.data.tag.id 72 + }; 73 + }, testUri); 74 + expect(setup.itemId).toBeTruthy(); 75 + 76 + // Open tags/home. 77 + const open = await bgWindow.evaluate(async () => { 78 + return await (window as any).app.window.open('peek://tags/home.html', { 79 + width: 900, height: 700, key: 'tags-toggle-test' 80 + }); 81 + }); 82 + expect(open.success).toBe(true); 83 + 84 + const tagsWindow = await app.getWindow('tags/home.html', 10000); 85 + expect(tagsWindow).toBeTruthy(); 86 + await tagsWindow.waitForLoadState('domcontentloaded'); 87 + 88 + // Wait for the card and its toggle affordance to render. 89 + const cardSelector = `peek-card[data-item-id="${setup.itemId}"]`; 90 + await tagsWindow.waitForSelector(cardSelector, { timeout: 10000 }); 91 + const toggleSelector = `${cardSelector} .tag-action-affordances .tag-action-toggle`; 92 + await tagsWindow.waitForSelector(toggleSelector, { timeout: 10000 }); 93 + 94 + // Initial state: not checked (item has `todo`, not `done`). 95 + const initialChecked = await tagsWindow.$eval(toggleSelector, (el) => 96 + el.classList.contains('checked') 97 + ); 98 + expect(initialChecked).toBe(false); 99 + 100 + // Click toggle — should remove `todo`, add `done`. 101 + await tagsWindow.click(toggleSelector); 102 + 103 + // Wait for the optimistic visual flip + datastore round-trip. 104 + await tagsWindow.waitForFunction( 105 + (sel) => { 106 + const btn = document.querySelector(sel) as HTMLElement | null; 107 + return btn?.classList.contains('checked'); 108 + }, 109 + toggleSelector, 110 + { timeout: 5000 } 111 + ); 112 + 113 + // Verify datastore: item now has `done`, not `todo`. 114 + const tagsAfter = await bgWindow.evaluate(async (itemId: string) => { 115 + const result = await (window as any).app.datastore.getItemTags(itemId); 116 + return result.success ? result.data.map((t: any) => t.name) : null; 117 + }, setup.itemId); 118 + expect(tagsAfter).toContain('done'); 119 + expect(tagsAfter).not.toContain('todo'); 120 + 121 + // Cleanup 122 + if (open.id) { 123 + try { 124 + await bgWindow.evaluate(async (id: number) => { 125 + return await (window as any).app.window.close(id); 126 + }, open.id); 127 + } catch { /* may already be closed */ } 128 + } 129 + }); 130 + 131 + test('groups/home gate permits tag-actions:get-all round-trip', async () => { 132 + const open = await bgWindow.evaluate(async () => { 133 + return await (window as any).app.window.open('peek://groups/home.html', { 134 + width: 800, height: 600, key: 'groups-tag-actions-test' 135 + }); 136 + }); 137 + expect(open.success).toBe(true); 138 + 139 + const groupsWindow = await app.getWindow('groups/home.html', 10000); 140 + expect(groupsWindow).toBeTruthy(); 141 + await groupsWindow.waitForLoadState('domcontentloaded'); 142 + 143 + const response = await fetchActionRules(groupsWindow); 144 + expect(response.success).toBe(true); 145 + expect(Array.isArray(response.data)).toBe(true); 146 + expect(response.data.length).toBeGreaterThan(0); 147 + 148 + if (open.id) { 149 + try { 150 + await bgWindow.evaluate(async (id: number) => { 151 + return await (window as any).app.window.close(id); 152 + }, open.id); 153 + } catch { /* may already be closed */ } 154 + } 155 + }); 156 + 157 + test('search manifest declares tag-actions topics', async () => { 158 + // Static manifest check — search/home has a separate, pre-existing pubsub 159 + // initialization quirk (its workspace key collapses opens, blocking a 160 + // runtime round-trip from a fresh test window) that's out of scope for 161 + // this fix. The manifest-level gate grant is what matters for the bug, 162 + // and the gate logic is identical to groups/pagestream which are 163 + // exercised at runtime above. 164 + const manifest = await bgWindow.evaluate(async () => { 165 + const res = await fetch('peek://search/manifest.json'); 166 + return await res.json(); 167 + }); 168 + const topics = manifest?.capabilities?.pubsub?.topics ?? []; 169 + expect(topics).toContain('tag-actions:get-all'); 170 + expect(topics).toContain('tag-actions:get-all:response'); 171 + expect(topics).toContain('tag-actions:create:response'); 172 + expect(topics).toContain('tag-actions:update:response'); 173 + expect(topics).toContain('tag-actions:delete:response'); 174 + }); 175 + 176 + test('pagestream/home gate permits tag-actions:get-all round-trip', async () => { 177 + const open = await bgWindow.evaluate(async () => { 178 + return await (window as any).app.window.open('peek://pagestream/home.html', { 179 + width: 600, height: 800, key: 'pagestream-tag-actions-test' 180 + }); 181 + }); 182 + expect(open.success).toBe(true); 183 + 184 + const psWindow = await app.getWindow('pagestream/home.html', 10000); 185 + expect(psWindow).toBeTruthy(); 186 + await psWindow.waitForLoadState('domcontentloaded'); 187 + 188 + const response = await fetchActionRules(psWindow); 189 + expect(response.success).toBe(true); 190 + expect(Array.isArray(response.data)).toBe(true); 191 + expect(response.data.length).toBeGreaterThan(0); 192 + 193 + if (open.id) { 194 + try { 195 + await bgWindow.evaluate(async (id: number) => { 196 + return await (window as any).app.window.close(id); 197 + }, open.id); 198 + } catch { /* may already be closed */ } 199 + } 200 + }); 201 + });