experiments in a post-browser web
10
fork

Configure Feed

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

refactor(tag-actions): collapse into single resident tile

Fourth consolidation in the series (after websearch, entities, lex).
Tag-actions had two tile entries with heavy request-response pubsub
round-trips between them.

Changes:
- manifest.json: two tile entries collapsed to one resident:true tile,
removed 4 internal pubsub topics from capabilities, command action
type changed from window to execute.
- home.js: merged ~300 lines of background.js logic — settings
load/save, migration from old actions format, pairsToActionRules
conversion, registerCommands (noun registration), and the external
pubsub handlers for tag-actions:get-all and the legacy CRUD
endpoints.

Internal pubsub eliminated (replaced with direct calls):
- tag-actions:get-pairs / tag-actions:get-pairs:response
- tag-actions:set-pairs / tag-actions:set-pairs:response

External pubsub preserved: tag-actions:get-all and the response
topics — used by app/lib/tag-action-affordances.js for cross-tile
affordance lookups, not internal bg/home coordination.

background.js and background.html left on disk.

+229 -67
+222 -38
features/tag-actions/home.js
··· 1 1 /** 2 - * Tag Actions - Simplified Tag Pairs UI 2 + * Tag Actions - Single Resident Tile 3 + * 4 + * Combines background state management and UI in one tile (resident: true). 5 + * State (pairs, action rules), settings I/O, command registration, and 6 + * external pubsub handlers (tag-actions:get-all, :create, :update, :delete) 7 + * all live here. Internal get-pairs/set-pairs request-response replaced with 8 + * direct function calls. 3 9 * 4 10 * Shows a flat list of tag pairs. Each pair has: 5 11 * - Active tag (e.g. "todo") 6 12 * - Completed tag (e.g. "done") 7 13 * - Icon style (checkbox, eye, star, etc.) 8 14 * 9 - * Communicates with background.js via pubsub request/response pattern. 15 + * External consumers (tag-action-affordances.js) still use: 16 + * tag-actions:get-all / tag-actions:get-all:response 17 + * tag-actions:create:response, :update:response, :delete:response 10 18 */ 19 + 20 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 11 21 12 22 const api = window.app; 13 23 const debug = api.debug; ··· 50 60 51 61 // ==================== State ==================== 52 62 53 - let pairs = []; 63 + let currentPairs = []; 64 + let currentActions = []; // derived from pairs 65 + 66 + // ==================== Settings ==================== 67 + 68 + const loadSettings = async () => { 69 + const result = await api.settings.get('data'); 70 + if (result.success && result.data) { 71 + // Support new pairs format 72 + if (Array.isArray(result.data.pairs)) { 73 + currentPairs = result.data.pairs; 74 + } 75 + // Migration: convert old actions format to pairs 76 + else if (Array.isArray(result.data.actions) && result.data.actions.length > 0) { 77 + currentPairs = migrateActionsToToPairs(result.data.actions); 78 + } else { 79 + currentPairs = []; 80 + } 81 + } else { 82 + currentPairs = []; 83 + } 54 84 55 - // ==================== Pubsub Helpers ==================== 85 + // Derive action rules from pairs 86 + currentActions = pairsToActionRules(currentPairs); 87 + debug && console.log('[tag-actions] Loaded', currentPairs.length, 'pairs ->', currentActions.length, 'action rules'); 88 + }; 56 89 57 - const request = (topic, data = {}) => { 58 - return new Promise((resolve) => { 59 - const handler = (msg) => { 60 - resolve(msg); 61 - }; 62 - api.pubsub.subscribe(`${topic}:response`, handler, api.scopes.GLOBAL); 63 - api.pubsub.publish(topic, data, api.scopes.GLOBAL); 64 - }); 90 + const saveSettings = async () => { 91 + const result = await api.settings.set('data', { pairs: currentPairs }); 92 + if (!result.success) { 93 + console.error('[tag-actions] Failed to save settings:', result.error); 94 + } 95 + // Regenerate action rules 96 + currentActions = pairsToActionRules(currentPairs); 97 + return result; 65 98 }; 66 99 67 - // ==================== Data Loading ==================== 100 + // ==================== Migration ==================== 68 101 69 - const loadPairs = async () => { 70 - const result = await request('tag-actions:get-pairs'); 71 - if (result.success && Array.isArray(result.data)) { 72 - pairs = result.data; 73 - } else { 74 - pairs = []; 102 + /** 103 + * Convert old-format actions array to new pairs format. 104 + * Only migrates toggle-type actions (the visual affordance type). 105 + */ 106 + const migrateActionsToToPairs = (actions) => { 107 + const pairs = []; 108 + for (const action of actions) { 109 + if (action.actionType === 'toggle' && action.actionConfig) { 110 + const config = action.actionConfig; 111 + // Map old icon names to new iconPair names 112 + let iconPair = 'checkbox'; 113 + if (config.icon === 'eye' || config.checkedIcon === 'eye') iconPair = 'eye'; 114 + else if (config.icon === 'star' || config.checkedIcon === 'star') iconPair = 'star'; 115 + else if (config.icon === 'heart' || config.checkedIcon === 'heart') iconPair = 'heart'; 116 + else if (config.icon === 'flag' || config.checkedIcon === 'flag') iconPair = 'flag'; 117 + else if (config.icon === 'bookmark' || config.checkedIcon === 'bookmark') iconPair = 'bookmark'; 118 + 119 + pairs.push({ 120 + activeTag: config.activeTag || action.triggerTag || '', 121 + completedTag: config.completedTag || '', 122 + iconPair 123 + }); 124 + } 75 125 } 76 - debug && console.log('[tag-actions:home] Loaded pairs:', pairs.length); 126 + return pairs; 77 127 }; 78 128 79 - const savePairs = async () => { 80 - await request('tag-actions:set-pairs', { pairs }); 81 - debug && console.log('[tag-actions:home] Saved pairs:', pairs.length); 129 + // ==================== Pairs to Action Rules ==================== 130 + 131 + /** 132 + * Icon pair name to the icon/checkedIcon names used by tag-action-affordances.js 133 + */ 134 + const ICON_PAIR_MAP = { 135 + checkbox: { icon: 'unchecked', checkedIcon: 'checked' }, 136 + eye: { icon: 'eye-open', checkedIcon: 'eye-closed' }, 137 + star: { icon: 'star-empty', checkedIcon: 'star-filled' }, 138 + heart: { icon: 'heart-empty', checkedIcon: 'heart-filled' }, 139 + flag: { icon: 'flag-empty', checkedIcon: 'flag-filled' }, 140 + bookmark: { icon: 'bookmark-empty', checkedIcon: 'bookmark-filled' } 141 + }; 142 + 143 + /** 144 + * Convert simplified pairs to action rule objects compatible with resolveAffordances(). 145 + */ 146 + const pairsToActionRules = (pairs) => { 147 + return pairs 148 + .filter(p => p.activeTag && p.completedTag) 149 + .map((pair, i) => { 150 + const icons = ICON_PAIR_MAP[pair.iconPair] || ICON_PAIR_MAP.checkbox; 151 + return { 152 + id: `pair_${i}`, 153 + name: `${pair.activeTag}/${pair.completedTag}`, 154 + enabled: true, 155 + triggerTag: pair.activeTag, 156 + triggerOn: 'add', 157 + actionType: 'toggle', 158 + actionConfig: { 159 + activeTag: pair.activeTag, 160 + completedTag: pair.completedTag, 161 + icon: icons.icon, 162 + checkedIcon: icons.checkedIcon, 163 + color: '#007aff', 164 + checkedColor: '#34c759' 165 + }, 166 + itemTypes: null 167 + }; 168 + }); 169 + }; 170 + 171 + // ==================== Commands ==================== 172 + 173 + const openTagActions = () => { 174 + api.window.open('peek://ext/tag-actions/home.html', { 175 + role: 'workspace', 176 + key: 'tag-actions-home', 177 + width: 560, 178 + height: 400, 179 + title: 'Tag Pairs' 180 + }); 181 + }; 182 + 183 + const registerCommands = () => { 184 + registerNoun({ 185 + name: 'tag actions', 186 + singular: 'tag action', 187 + description: 'Tag pair toggles on item cards', 188 + 189 + query: async ({ search }) => { 190 + let filtered = currentPairs; 191 + if (search) { 192 + const s = search.toLowerCase(); 193 + filtered = filtered.filter(p => 194 + p.activeTag.toLowerCase().includes(s) || 195 + p.completedTag.toLowerCase().includes(s) 196 + ); 197 + } 198 + if (filtered.length === 0) { 199 + return { output: 'No tag pairs found.', mimeType: 'text/plain' }; 200 + } 201 + return { 202 + success: true, 203 + output: { 204 + data: filtered, 205 + mimeType: 'application/json', 206 + title: `Tag Pairs (${filtered.length})` 207 + } 208 + }; 209 + }, 210 + 211 + browse: async () => { openTagActions(); }, 212 + 213 + create: async ({ search }) => { 214 + currentPairs.push({ activeTag: search || '', completedTag: '', iconPair: 'checkbox' }); 215 + await saveSettings(); 216 + openTagActions(); 217 + return { success: true }; 218 + }, 219 + 220 + produces: 'application/json' 221 + }); 222 + }; 223 + 224 + // ==================== External Pubsub Handlers ==================== 225 + // These topics are consumed by tag-action-affordances.js (external shared lib) 226 + // and must remain as pubsub even in the single-tile model. 227 + 228 + const registerExternalHandlers = () => { 229 + // tag-action-affordances.js queries for action rules 230 + api.pubsub.subscribe('tag-actions:get-all', () => { 231 + api.pubsub.publish('tag-actions:get-all:response', { 232 + success: true, 233 + data: currentActions 234 + }, api.scopes.GLOBAL); 235 + }, api.scopes.GLOBAL); 236 + 237 + // Legacy CRUD endpoints -- respond gracefully; pairs is the source of truth 238 + api.pubsub.subscribe('tag-actions:create', async (msg) => { 239 + // If it looks like a toggle action, convert to pair 240 + if (msg.actionType === 'toggle' && msg.actionConfig) { 241 + currentPairs.push({ 242 + activeTag: msg.actionConfig.activeTag || msg.triggerTag || '', 243 + completedTag: msg.actionConfig.completedTag || '', 244 + iconPair: 'checkbox' 245 + }); 246 + await saveSettings(); 247 + } 248 + api.pubsub.publish('tag-actions:create:response', { success: true }, api.scopes.GLOBAL); 249 + }, api.scopes.GLOBAL); 250 + 251 + api.pubsub.subscribe('tag-actions:update', async (_msg) => { 252 + api.pubsub.publish('tag-actions:update:response', { success: true }, api.scopes.GLOBAL); 253 + }, api.scopes.GLOBAL); 254 + 255 + api.pubsub.subscribe('tag-actions:delete', async (_msg) => { 256 + api.pubsub.publish('tag-actions:delete:response', { success: true }, api.scopes.GLOBAL); 257 + }, api.scopes.GLOBAL); 82 258 }; 83 259 84 260 // ==================== Escape Handler ==================== ··· 93 269 const container = document.querySelector('.pairs-list'); 94 270 container.innerHTML = ''; 95 271 96 - if (pairs.length === 0) { 272 + if (currentPairs.length === 0) { 97 273 container.innerHTML = '<div class="empty-state">No tag pairs yet. Click + to create one.</div>'; 98 274 return; 99 275 } 100 276 101 - pairs.forEach((pair, index) => { 277 + currentPairs.forEach((pair, index) => { 102 278 container.appendChild(createPairRow(pair, index)); 103 279 }); 104 280 }; ··· 125 301 activeInput.placeholder = 'active tag'; 126 302 activeInput.addEventListener('change', (e) => { 127 303 pair.activeTag = e.target.value.trim(); 128 - savePairs(); 304 + saveSettings(); 129 305 }); 130 306 tagsArea.appendChild(activeInput); 131 307 ··· 142 318 completedInput.placeholder = 'completed tag'; 143 319 completedInput.addEventListener('change', (e) => { 144 320 pair.completedTag = e.target.value.trim(); 145 - savePairs(); 321 + saveSettings(); 146 322 }); 147 323 tagsArea.appendChild(completedInput); 148 324 ··· 163 339 // Update icon preview 164 340 const newStyle = ICON_STYLES[pair.iconPair] || ICON_STYLES.checkbox; 165 341 iconPreview.innerHTML = newStyle.svg; 166 - savePairs(); 342 + saveSettings(); 167 343 }); 168 344 row.appendChild(iconSelect); 169 345 ··· 173 349 deleteBtn.title = 'Remove pair'; 174 350 deleteBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'; 175 351 deleteBtn.addEventListener('click', async () => { 176 - pairs.splice(index, 1); 177 - await savePairs(); 352 + currentPairs.splice(index, 1); 353 + await saveSettings(); 178 354 render(); 179 355 }); 180 356 row.appendChild(deleteBtn); ··· 185 361 // ==================== Init ==================== 186 362 187 363 const init = async () => { 188 - debug && console.log('[tag-actions:home] init'); 364 + debug && console.log('[tag-actions] init'); 189 365 190 - api.escape.onEscape(handleEscape); 366 + await loadSettings(); 367 + registerCommands(); 368 + registerExternalHandlers(); 191 369 192 - await loadPairs(); 370 + api.escape.onEscape(handleEscape); 193 371 194 372 // If no pairs exist, populate defaults 195 - if (pairs.length === 0) { 196 - pairs = DEFAULT_PAIRS.map(p => ({ ...p })); 197 - await savePairs(); 373 + if (currentPairs.length === 0) { 374 + currentPairs = DEFAULT_PAIRS.map(p => ({ ...p })); 375 + await saveSettings(); 198 376 } 199 377 200 378 // Add pair button 201 379 document.querySelector('.add-pair-btn').addEventListener('click', async () => { 202 - pairs.push({ activeTag: '', completedTag: '', iconPair: 'checkbox' }); 203 - await savePairs(); 380 + currentPairs.push({ activeTag: '', completedTag: '', iconPair: 'checkbox' }); 381 + await saveSettings(); 204 382 render(); 205 383 // Focus the first empty input in the new row 206 384 requestAnimationFrame(() => { ··· 214 392 }); 215 393 216 394 render(); 395 + 396 + debug && console.log('[tag-actions] initialized with', currentPairs.length, 'pairs'); 397 + }; 398 + 399 + const uninit = () => { 400 + unregisterNoun('tag actions'); 217 401 }; 218 402 219 403 document.addEventListener('DOMContentLoaded', init);
+7 -29
features/tag-actions/manifest.json
··· 9 9 "settingsSchema": "./settings-schema.json", 10 10 "tiles": [ 11 11 { 12 - "id": "background", 13 - "type": "background", 14 - "url": "background.html", 15 - "lazy": true 16 - }, 17 - { 18 12 "id": "home", 19 - "type": "window", 20 13 "url": "home.html", 21 - "windowHints": { 22 - "role": "workspace", 23 - "key": "tag-actions-home", 24 - "width": 560, 25 - "height": 400, 26 - "title": "Tag Pairs" 27 - } 14 + "width": 560, 15 + "height": 400, 16 + "title": "Tag Pairs", 17 + "role": "workspace", 18 + "key": "tag-actions-home", 19 + "resident": true 28 20 } 29 21 ], 30 22 "capabilities": { ··· 34 26 "ext:ready", 35 27 "ext:tag-actions:shutdown", 36 28 "app:shutdown", 37 - "tag-actions:get-pairs", 38 - "tag-actions:get-pairs:response", 39 - "tag-actions:set-pairs", 40 - "tag-actions:set-pairs:response", 41 29 "tag-actions:get-all", 42 30 "tag-actions:get-all:response", 43 31 "tag-actions:create", ··· 58 46 { 59 47 "name": "tag actions", 60 48 "description": "Open the tag actions manager", 61 - "action": { 62 - "type": "window", 63 - "url": "peek://ext/tag-actions/home.html", 64 - "options": { 65 - "role": "workspace", 66 - "key": "tag-actions-home", 67 - "width": 560, 68 - "height": 400, 69 - "title": "Tag Pairs" 70 - } 71 - } 49 + "action": { "type": "execute" } 72 50 } 73 51 ] 74 52 }