experiments in a post-browser web
10
fork

Configure Feed

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

fix(websearch): consolidate to single tile + fix multi-word command query bleed

Two related changes to the websearch feature:

1. Delete orphan `background.html` / `background.js`. The manifest already
collapsed to a single resident `home` tile in earlier work; `home.js`
already owns engine state + UI. The background files were unreferenced
from the manifest but lingering on disk. All 10 websearch desktop
tests green after deletion.

2. Fix `cmd → web search` (no query) opening the default engine for the
literal string "search". Root cause in `extractSearchQuery`: when
`ctx.typed === ctx.name === "web search"`, steps 1-4 returned no query,
then the step-5 fallback (`typed.indexOf(' ')`) treated the second word
of the command name as the query. Fix: only run the step-5 fallback
when `ctx.name` is missing - preserves its "name unknown" intent
without bleeding multi-word command names. New Playwright regression
test in `tests/desktop-serial/websearch-cmd.spec.ts` types
"web search" with no query and asserts the home tile is shown
instead of a search-results URL.

Also `docs/tasks.md` updates: replaced the websearch consolidation task
with audit findings (tags + lists are next candidates), added a
2026-04-27 pruned-log entry, and recorded two new bugs - undo close
window (cmd+shift+t) is unreliable, and page-load failures (e.g.
metikmusic.com) show a blank white page forever instead of a
Peek-branded error UI.

+67 -874
+11 -1
docs/tasks.md
··· 10 10 11 11 ## Tile architecture cleanup 12 12 13 - - [ ] **Merge websearch's separate background tile into the home window.** Manifest has two tile entries: `background.html` (lazy:true) for settings/engine state, `home.html` for the UI. They communicate via pubsub round-trips (`websearch:engine-request` → `websearch:engines-list`) across `peek://ext/websearch/background.html` vs `peek://websearch/home.html`. Cross-window pubsub within one feature is fragile (see 2026-04-20 session — broadcaster echo-prevention bug + cluster 3 regression risk). The round-trip also blocks 3 websearch tests from passing. With the single-file tiles model shipping (`resident: true`), websearch can collapse to one tile whose home window owns both UI and engine state directly. No IPC needed. Applies also to any feature tile with a bg + window pair that pubsubs between itself — audit other candidates. 13 + - [ ] **Collapse `tags` and `lists` bg+window pubsub round-trips.** Audit on 2026-04-27 found the websearch consolidation pattern still needs to be applied to two more features: 14 + - **tags** — 3 tiles (background lazy:true, home, plus search.js); background publishes `editor:changed`, `editor:add` to home. 15 + - **lists** — 2 tiles (background lazy:true, home); background↔home round-trip on `lists:settings-changed` / `lists:settings-update` for hotloading. 16 + - **editor**, **spaces** — minimal cross-tile pubsub (lifecycle / cmd registration only); deprioritize. 17 + - **sheets**, **feeds**, **search** — bg tile is pure lifecycle, no data round-trip; skip. 18 + Same approach as websearch: collapse to single resident tile, move state into home.js, delete background.{html,js}. 14 19 15 20 --- 16 21 17 22 ## Bugs 23 + 24 + - [ ] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/` (DNS resolves but server returns nothing useful, or DNS fails). The page-host webview just sits on white with no feedback. We should handle the full lifecycle of a failed page open: DNS failure, connection refused, TLS errors, HTTP 4xx/5xx, hung loads, ERR_NAME_NOT_RESOLVED, ERR_INTERNET_DISCONNECTED. Show a Peek-branded error UI inside the canvas with: the URL that failed, the underlying error reason, an obvious Retry button, and a "Go back" / "Close" affordance. Likely hooks: `did-fail-load` and `did-fail-provisional-load` on the webview's webContents in `app/page/page.js`, plus `certificate-error` on the session. Audit other entry points too (cmd web search, external URL handler, address bar) to ensure they all funnel into the same error UI. 25 + 26 + - [ ] **Undo close window (cmd+shift+t) doesn't work a lot of the time.** User-reported 2026-04-27. The shortcut sometimes fails to reopen the most recently closed window. Needs repro + diagnosis: which window types is it failing for (canvas/page, cmd, hud, tile)? Is the closed-window stack being populated for all close paths (page-host close, tile:window:close, app-quit cleanup, accidental close vs hide)? Suspect interaction with `closeOrHideWindow`/`tile:lifecycle:visible` reuse — a hidden-then-shown window may be wrongly skipped from the undo stack, or the stack may be cleared on hide. Also check: does it work for the most recent close but not for the second-most-recent? Does the shortcut fire at all (verify `before-input-event`/accelerator binding) or does it fire and produce nothing? Start with `console.log` of the undo handler and the stack contents on close. 18 27 19 28 - [ ] **Tauri: rename `pubsub:ext:*` startup topics to `pubsub:feature:*`.** `backend/tauri/src-tauri/src/lib.rs` still emits `pubsub:ext:startup:phase` and `pubsub:ext:all-loaded`. Electron side renamed 2026-04-24; Tauri/mobile is on hold per the v1-removal plan, but track this so it lands when Tauri is unfrozen. 20 29 - [ ] **Migrate bootstrap IPC channels to tile:lifecycle:* namespace.** `session-restore-pending` and `frontend-ready` in `backend/electron/entry.ts` are bare `ipcMain.handle()` registrations from before the tile system initializes. They're only reachable from trustedBuiltin renderers via `api.invoke()`, so the security gap is theoretical, but they're the last bare main-process IPC handlers outside `tile-ipc-gate.ts`. Decision skipped 2026-04-24: leaving them bare for now since they exist *before* any tile loads — the indirection through tile-ipc-gate would require either bootstrapping the gate earlier or having two IPC modes (bootstrap vs. post-init). Worth revisiting if tile-ipc-gate ever gains a "no token required for these specific channels" mode, or if entry.ts gets folded into a tile lifecycle. ··· 65 74 66 75 Keep short — for recent context only. Prune after a few weeks. 67 76 77 + - 2026-04-27 Websearch bg+window consolidation finished: orphan `features/websearch/background.{html,js}` deleted (manifest already collapsed to a single `home` resident tile in earlier work; engine state + UI both live in `home.js`). All 10 websearch desktop tests green; no other refs in tree. Audit of remaining bg+window pubsub pairs added to "Tile architecture cleanup" — `tags` and `lists` are the next candidates. 68 78 - 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. 69 79 - 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. 70 80 - 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.
-37
features/websearch/background.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; connect-src https: http:;"> 6 - <title>Web Search Extension</title> 7 - </head> 8 - <body> 9 - <script type="module"> 10 - import extension from './background.js'; 11 - 12 - const api = window.app; 13 - const extId = extension.id; 14 - 15 - console.log(`[ext:${extId}] background.html loaded`); 16 - 17 - // ── V2 Tile Runtime ── 18 - // Initialize tile — validates capability token with main process 19 - console.log(`[ext:${extId}] initializing v2 tile`); 20 - await api.initialize(); 21 - 22 - // Initialize extension (registers commands, shortcuts) 23 - if (extension.init) { 24 - console.log(`[ext:${extId}] calling init()`); 25 - await extension.init(); 26 - } 27 - 28 - // Register shutdown handler 29 - api.onShutdown(() => { 30 - console.log(`[ext:${extId}] received shutdown`); 31 - if (extension.uninit) { 32 - extension.uninit(); 33 - } 34 - }); 35 - </script> 36 - </body> 37 - </html>
-833
features/websearch/background.js
··· 1 - /** 2 - * Web Search Extension Background Script 3 - * 4 - * Provides web search from the command palette: 5 - * - Built-in engines: Google, DuckDuckGo, Bing, Wikipedia, Kagi 6 - * - OpenSearch discovery from visited pages 7 - * - Search suggestions with debounce and caching 8 - * - Per-engine commands (/google, /ddg, /bing, /wiki, /kagi) 9 - * - A general "web search" command with engine selection 10 - * 11 - * Commands are declared in manifest.json. 12 - * This extension loads lazily on first command invocation. 13 - * 14 - * Runs in isolated extension process (peek://websearch/background.html) 15 - * Uses api.settings for datastore-backed settings storage 16 - */ 17 - 18 - import { id, labels, schemas, storageKeys, defaults, builtinEngines } from './config.js'; 19 - 20 - const api = window.app; 21 - const debug = api.debug; 22 - 23 - // ===== IPC pubsub messaging ===== 24 - // Background runs at peek://websearch/ while home runs at peek://websearch/ 25 - // — different origins, so BroadcastChannel cannot work. Use IPC pubsub instead. 26 - 27 - function onChannel(topic, handler) { 28 - api.pubsub.subscribe(topic, handler); 29 - } 30 - 31 - function emitChannel(topic, data) { 32 - api.pubsub.publish(topic, data); 33 - } 34 - 35 - const address = 'peek://websearch/home.html'; 36 - 37 - // ===== State ===== 38 - 39 - // In-memory settings cache (loaded from datastore on init) 40 - let currentSettings = { 41 - prefs: defaults.prefs 42 - }; 43 - 44 - // All engines: built-in + custom/discovered 45 - let engines = [...builtinEngines]; 46 - 47 - // Suggestion cache: Map<cacheKey, { suggestions, timestamp }> 48 - const suggestionCache = new Map(); 49 - const SUGGESTION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 50 - const SUGGESTION_CACHE_MAX = 500; 51 - 52 - // Track in-flight suggestion requests for cancellation 53 - let currentSuggestionAbortController = null; 54 - 55 - // Track the search window ID 56 - let searchWindowId = null; 57 - 58 - // ===== Settings ===== 59 - 60 - /** 61 - * Load settings from datastore 62 - */ 63 - const loadSettings = async () => { 64 - const result = await api.settings.get('prefs'); 65 - if (result.success && result.data) { 66 - return { 67 - prefs: { ...defaults.prefs, ...(result.data || {}) } 68 - }; 69 - } 70 - return { prefs: { ...defaults.prefs } }; 71 - }; 72 - 73 - /** 74 - * Save settings to datastore 75 - */ 76 - const saveSettings = async (settings) => { 77 - const result = await api.settings.set('prefs', settings.prefs); 78 - if (!result.success) { 79 - console.error('[ext:websearch] Failed to save settings:', result.error); 80 - } 81 - }; 82 - 83 - // ===== Engine Registry ===== 84 - 85 - /** 86 - * Load custom/discovered engines from settings 87 - */ 88 - const loadEngines = async () => { 89 - const result = await api.settings.get('engines'); 90 - const customEngines = (result.success && result.data) || []; 91 - 92 - // Merge built-in with custom, custom engines can override built-in ones by ID 93 - const customIds = new Set(customEngines.map(e => e.id)); 94 - engines = [ 95 - ...builtinEngines.filter(e => !customIds.has(e.id)), 96 - ...customEngines 97 - ]; 98 - 99 - debug && console.log('[ext:websearch] Loaded engines:', engines.map(e => e.id)); 100 - }; 101 - 102 - /** 103 - * Save custom engines to settings (only non-builtin ones) 104 - */ 105 - const saveEngines = async () => { 106 - const customEngines = engines.filter(e => !e.isBuiltIn); 107 - await api.settings.set('engines', customEngines); 108 - }; 109 - 110 - /** 111 - * Get an engine by ID 112 - */ 113 - const getEngine = (engineId) => { 114 - return engines.find(e => e.id === engineId) || null; 115 - }; 116 - 117 - /** 118 - * Get an engine by short name (keyword) 119 - */ 120 - const getEngineByShortName = (shortName) => { 121 - return engines.find(e => e.shortName === shortName && e.enabled !== false) || null; 122 - }; 123 - 124 - /** 125 - * Get the default engine 126 - */ 127 - const getDefaultEngine = () => { 128 - const defaultId = currentSettings.prefs.defaultEngine; 129 - return getEngine(defaultId) || engines.find(e => e.enabled !== false) || engines[0]; 130 - }; 131 - 132 - /** 133 - * Get all enabled engines 134 - */ 135 - const getEnabledEngines = () => { 136 - return engines.filter(e => e.enabled !== false); 137 - }; 138 - 139 - /** 140 - * Add a discovered/custom engine 141 - */ 142 - const addEngine = async (engine) => { 143 - // Check for duplicate ID 144 - const existingIndex = engines.findIndex(e => e.id === engine.id); 145 - if (existingIndex >= 0) { 146 - // Update existing 147 - engines[existingIndex] = { ...engines[existingIndex], ...engine }; 148 - } else { 149 - engines.push(engine); 150 - } 151 - await saveEngines(); 152 - 153 - // Re-register commands for the new engine 154 - unregisterEngineCommands(); 155 - registerEngineCommands(); 156 - 157 - debug && console.log('[ext:websearch] Added engine:', engine.id, engine.name); 158 - }; 159 - 160 - /** 161 - * Remove a custom engine (cannot remove built-in) 162 - */ 163 - const removeEngine = async (engineId) => { 164 - const engine = getEngine(engineId); 165 - if (!engine) return false; 166 - if (engine.isBuiltIn) { 167 - console.warn('[ext:websearch] Cannot remove built-in engine:', engineId); 168 - return false; 169 - } 170 - 171 - engines = engines.filter(e => e.id !== engineId); 172 - await saveEngines(); 173 - 174 - // Re-register commands 175 - unregisterEngineCommands(); 176 - registerEngineCommands(); 177 - 178 - return true; 179 - }; 180 - 181 - // ===== Search URL Construction ===== 182 - 183 - /** 184 - * Build a search URL from a template and query 185 - */ 186 - const buildSearchUrl = (urlTemplate, query) => { 187 - return urlTemplate.replace('{searchTerms}', encodeURIComponent(query)); 188 - }; 189 - 190 - /** 191 - * Execute a search: open the search results URL in a new window 192 - */ 193 - const executeSearch = async (engine, query) => { 194 - if (!query || !query.trim()) return; 195 - 196 - const url = buildSearchUrl(engine.searchUrl, query.trim()); 197 - debug && console.log('[ext:websearch] Searching:', engine.name, query, '->', url); 198 - 199 - api.window.open(url, { 200 - role: 'content', 201 - width: 1024, 202 - height: 768, 203 - trackingSource: 'websearch', 204 - trackingSourceId: engine.id 205 - }).catch(err => { 206 - console.error('[ext:websearch] Failed to open search window:', err); 207 - }); 208 - }; 209 - 210 - // ===== Search Suggestions ===== 211 - 212 - /** 213 - * Fetch suggestions from a search engine's suggest API. 214 - * Supports OpenSearch JSON format: ["query", ["suggestion1", "suggestion2", ...]] 215 - */ 216 - const fetchSuggestions = async (engine, query, signal) => { 217 - if (!engine.suggestUrl || !query || !query.trim()) return []; 218 - if (!currentSettings.prefs.enableSuggestions) return []; 219 - 220 - const url = buildSearchUrl(engine.suggestUrl, query.trim()); 221 - 222 - try { 223 - const controller = new AbortController(); 224 - const timeout = setTimeout(() => controller.abort(), 3000); 225 - if (signal) signal.addEventListener('abort', () => controller.abort()); 226 - 227 - const res = await fetch(url, { 228 - headers: { 'Accept': 'application/json' }, 229 - signal: controller.signal, 230 - }); 231 - clearTimeout(timeout); 232 - 233 - if (signal && signal.aborted) return []; 234 - 235 - if (!res.ok) { 236 - debug && console.log('[ext:websearch] Suggestion fetch failed:', res.status); 237 - return []; 238 - } 239 - 240 - const data = await res.json(); 241 - 242 - // OpenSearch suggestion format: ["query", ["suggestion1", "suggestion2"]] 243 - if (Array.isArray(data) && data.length >= 2 && Array.isArray(data[1])) { 244 - return data[1].slice(0, 8); 245 - } 246 - 247 - // Google-style: { suggestions: [{ value: "..." }] } 248 - if (data && Array.isArray(data.suggestions)) { 249 - return data.suggestions.map(s => s.value || s).slice(0, 8); 250 - } 251 - 252 - return []; 253 - } catch (err) { 254 - if (err.name !== 'AbortError') { 255 - debug && console.log('[ext:websearch] Suggestion fetch failed:', err.message); 256 - } 257 - return []; 258 - } 259 - }; 260 - 261 - /** 262 - * Fetch suggestions with in-memory caching 263 - */ 264 - const fetchSuggestionsWithCache = async (engine, query, signal) => { 265 - const cacheKey = `${engine.id}:${query.toLowerCase().trim()}`; 266 - const cached = suggestionCache.get(cacheKey); 267 - 268 - if (cached && Date.now() - cached.timestamp < SUGGESTION_CACHE_TTL) { 269 - return cached.suggestions; 270 - } 271 - 272 - const suggestions = await fetchSuggestions(engine, query, signal); 273 - 274 - suggestionCache.set(cacheKey, { 275 - suggestions, 276 - timestamp: Date.now() 277 - }); 278 - 279 - // Evict oldest entries if cache is too large 280 - if (suggestionCache.size > SUGGESTION_CACHE_MAX) { 281 - const oldestKey = suggestionCache.keys().next().value; 282 - suggestionCache.delete(oldestKey); 283 - } 284 - 285 - return suggestions; 286 - }; 287 - 288 - /** 289 - * Clear the suggestion cache 290 - */ 291 - const clearSuggestionCache = () => { 292 - suggestionCache.clear(); 293 - }; 294 - 295 - // ===== OpenSearch Discovery ===== 296 - 297 - /** 298 - * Parse an OpenSearch XML description document 299 - * Returns an engine object or null if invalid 300 - */ 301 - const parseOpenSearchXml = (xmlText, sourceUrl) => { 302 - try { 303 - const parser = new DOMParser(); 304 - const doc = parser.parseFromString(xmlText, 'application/xml'); 305 - 306 - // Check for parse errors 307 - const parseError = doc.querySelector('parsererror'); 308 - if (parseError) { 309 - debug && console.log('[ext:websearch] OpenSearch XML parse error'); 310 - return null; 311 - } 312 - 313 - const ns = 'http://a9.com/-/spec/opensearch/1.1/'; 314 - 315 - const shortName = doc.getElementsByTagNameNS(ns, 'ShortName')[0]?.textContent?.trim(); 316 - const description = doc.getElementsByTagNameNS(ns, 'Description')[0]?.textContent?.trim(); 317 - const imageEl = doc.getElementsByTagNameNS(ns, 'Image')[0]; 318 - const iconUrl = imageEl?.textContent?.trim(); 319 - 320 - // Find search URL (type="text/html") 321 - let searchUrl = null; 322 - let suggestUrl = null; 323 - 324 - const urlElements = doc.getElementsByTagNameNS(ns, 'Url'); 325 - for (const urlEl of urlElements) { 326 - const type = urlEl.getAttribute('type'); 327 - const template = urlEl.getAttribute('template'); 328 - if (!template) continue; 329 - 330 - if (type === 'text/html' && !searchUrl) { 331 - searchUrl = template; 332 - } else if ((type === 'application/x-suggestions+json' || type === 'application/json') && !suggestUrl) { 333 - suggestUrl = template; 334 - } 335 - } 336 - 337 - if (!shortName || !searchUrl) { 338 - debug && console.log('[ext:websearch] OpenSearch XML missing required fields (ShortName, Url)'); 339 - return null; 340 - } 341 - 342 - // Validate that searchUrl contains {searchTerms} 343 - if (!searchUrl.includes('{searchTerms}')) { 344 - debug && console.log('[ext:websearch] OpenSearch search URL missing {searchTerms} placeholder'); 345 - return null; 346 - } 347 - 348 - // Reject javascript: URLs 349 - if (searchUrl.startsWith('javascript:') || (suggestUrl && suggestUrl.startsWith('javascript:'))) { 350 - debug && console.log('[ext:websearch] Rejecting javascript: URL in OpenSearch'); 351 - return null; 352 - } 353 - 354 - // Generate a stable ID from the source URL 355 - const hostname = new URL(sourceUrl).hostname; 356 - const engineId = `discovered-${hostname.replace(/\./g, '-')}`; 357 - 358 - // Generate a short keyword from the hostname 359 - const shortNameKeyword = hostname 360 - .replace(/^www\./, '') 361 - .split('.')[0] 362 - .toLowerCase() 363 - .replace(/[^a-z0-9]/g, '') 364 - .slice(0, 12); 365 - 366 - return { 367 - id: engineId, 368 - name: shortName, 369 - shortName: shortNameKeyword, 370 - description: description || `Search ${shortName}`, 371 - searchUrl, 372 - suggestUrl: suggestUrl || null, 373 - iconUrl: iconUrl || null, 374 - isBuiltIn: false, 375 - enabled: true, 376 - discoveredFrom: sourceUrl, 377 - discoveredAt: Date.now() 378 - }; 379 - } catch (err) { 380 - console.error('[ext:websearch] Failed to parse OpenSearch XML:', err); 381 - return null; 382 - } 383 - }; 384 - 385 - /** 386 - * Attempt to discover and add a search engine from an OpenSearch URL 387 - */ 388 - const discoverEngine = async (opensearchUrl, pageUrl) => { 389 - if (!currentSettings.prefs.enableAutoDiscovery) return null; 390 - 391 - try { 392 - const response = await fetch(opensearchUrl, { 393 - method: 'GET', 394 - headers: { 'Accept': 'application/opensearchdescription+xml, application/xml, text/xml' }, 395 - signal: AbortSignal.timeout(5000) 396 - }); 397 - 398 - if (!response.ok) return null; 399 - 400 - const xmlText = await response.text(); 401 - const engine = parseOpenSearchXml(xmlText, pageUrl); 402 - 403 - if (!engine) return null; 404 - 405 - // Check if we already have this engine 406 - const existing = getEngine(engine.id); 407 - if (existing) { 408 - debug && console.log('[ext:websearch] Engine already registered:', engine.id); 409 - return existing; 410 - } 411 - 412 - // Auto-add the discovered engine 413 - await addEngine(engine); 414 - 415 - // Publish discovery event for UI notifications 416 - api.pubsub.publish('websearch:engine-discovered', { 417 - engine: { id: engine.id, name: engine.name, searchUrl: engine.searchUrl } 418 - }); 419 - 420 - debug && console.log('[ext:websearch] Discovered search engine:', engine.name, 'from', pageUrl); 421 - return engine; 422 - } catch (err) { 423 - debug && console.log('[ext:websearch] OpenSearch discovery failed:', err.message); 424 - return null; 425 - } 426 - }; 427 - 428 - // ===== Search Window ===== 429 - 430 - let isOpeningSearch = false; 431 - const openSearchWindow = () => { 432 - if (isOpeningSearch) return; 433 - isOpeningSearch = true; 434 - 435 - const params = { 436 - role: 'workspace', 437 - key: address, 438 - height: 500, 439 - width: 600, 440 - trackingSource: 'cmd', 441 - trackingSourceId: 'websearch' 442 - }; 443 - 444 - api.window.open(address, params).then(window => { 445 - debug && console.log('[ext:websearch] Search window opened:', window); 446 - searchWindowId = window?.id || null; 447 - }).catch(error => { 448 - console.error('[ext:websearch] Failed to open search window:', error); 449 - }).finally(() => { 450 - isOpeningSearch = false; 451 - }); 452 - }; 453 - 454 - // ===== Query Extraction ===== 455 - 456 - /** 457 - * Extract the search query from a command execution context. 458 - * 459 - * The query can arrive in different fields depending on how the command 460 - * was invoked: 461 - * - Inline ("kagi foo"): ctx.search = "foo", ctx.params = ["foo"] 462 - * - Tab param mode: ctx.search = "foo", ctx.params = ["foo"] 463 - * - Direct pubsub: ctx.search = "foo" 464 - * 465 - * We check ctx.search first, then fall back to joining ctx.params, 466 - * then try extracting from ctx.typed by stripping the command name. 467 - */ 468 - const extractSearchQuery = (ctx) => { 469 - // 1. Explicit search field (set by buildExecutionContext when text follows command name) 470 - if (ctx.search && ctx.search.trim()) { 471 - return ctx.search.trim(); 472 - } 473 - 474 - // 2. Params array (same source as search, but as split tokens) 475 - if (ctx.params && ctx.params.length > 0) { 476 - const joined = ctx.params.join(' ').trim(); 477 - if (joined) return joined; 478 - } 479 - 480 - // 3. Extract from typed text by stripping the command name 481 - if (ctx.typed && ctx.name) { 482 - const typed = ctx.typed.trim(); 483 - const nameLower = ctx.name.toLowerCase(); 484 - const typedLower = typed.toLowerCase(); 485 - if (typedLower.startsWith(nameLower + ' ')) { 486 - const rest = typed.slice(ctx.name.length).trim(); 487 - if (rest) return rest; 488 - } else if (typedLower.startsWith(nameLower)) { 489 - const rest = typed.slice(ctx.name.length).trim(); 490 - if (rest) return rest; 491 - } 492 - } 493 - 494 - // 4. Fallback: extract from value field (Enter payload passes value=input.value) 495 - if (ctx.value && ctx.name) { 496 - const val = ctx.value.trim(); 497 - const nameLower = ctx.name.toLowerCase(); 498 - const valLower = val.toLowerCase(); 499 - if (valLower.startsWith(nameLower + ' ')) { 500 - const rest = val.slice(ctx.name.length).trim(); 501 - if (rest) return rest; 502 - } 503 - } 504 - 505 - // 5. Last resort: if typed contains a space, take everything after the first space 506 - // (handles case where name field might be missing) 507 - if (ctx.typed) { 508 - const spaceIdx = ctx.typed.indexOf(' '); 509 - if (spaceIdx > 0) { 510 - const rest = ctx.typed.slice(spaceIdx + 1).trim(); 511 - if (rest) return rest; 512 - } 513 - } 514 - 515 - return ''; 516 - }; 517 - 518 - // ===== Command Definitions ===== 519 - 520 - const staticCommandDefinitions = [ 521 - { 522 - name: 'web search', 523 - description: 'Search the web with your default search engine', 524 - params: [{ name: 'query', type: 'string', required: false, description: 'Search query' }], 525 - execute: async (ctx) => { 526 - debug && console.log('[ext:websearch] web search execute:', 527 - 'ctx keys:', ctx ? Object.keys(ctx) : 'null', 528 - 'search:', ctx?.search, 'params:', ctx?.params, 'typed:', ctx?.typed); 529 - const query = extractSearchQuery(ctx); 530 - if (query) { 531 - const engine = getDefaultEngine(); 532 - await executeSearch(engine, query); 533 - } else { 534 - console.warn('[ext:websearch] No query extracted for web search - opening search window. ctx:', JSON.stringify(ctx)); 535 - openSearchWindow(); 536 - } 537 - return { success: true }; 538 - } 539 - }, 540 - { 541 - name: 'open web search', 542 - description: 'Open the web search window', 543 - execute: async (ctx) => { 544 - openSearchWindow(); 545 - return { success: true }; 546 - } 547 - } 548 - ]; 549 - 550 - // Track registered commands for cleanup 551 - let registeredCommands = []; 552 - let engineCommands = []; 553 - 554 - /** 555 - * Register per-engine commands (e.g., /google, /ddg, /bing, /wiki) 556 - */ 557 - const registerEngineCommands = () => { 558 - const enabledEngines = getEnabledEngines(); 559 - 560 - for (const engine of enabledEngines) { 561 - const cmd = { 562 - name: engine.shortName, 563 - description: `Search ${engine.name}`, 564 - params: [{ name: 'query', type: 'string', required: false, description: 'Search query' }], 565 - execute: async (ctx) => { 566 - debug && console.log('[ext:websearch] Engine command execute:', engine.shortName, 567 - 'ctx keys:', ctx ? Object.keys(ctx) : 'null', 568 - 'search:', ctx?.search, 'params:', ctx?.params, 'typed:', ctx?.typed, 'name:', ctx?.name); 569 - const query = extractSearchQuery(ctx); 570 - debug && console.log('[ext:websearch] Extracted query:', JSON.stringify(query), 'from ctx for', engine.shortName); 571 - if (query) { 572 - await executeSearch(engine, query); 573 - } else { 574 - console.warn('[ext:websearch] No query extracted for', engine.shortName, '- opening search window. ctx:', JSON.stringify(ctx)); 575 - openSearchWindow(); 576 - } 577 - return { success: true }; 578 - } 579 - }; 580 - api.commands.register(cmd); 581 - engineCommands.push(engine.shortName); 582 - } 583 - 584 - debug && console.log('[ext:websearch] Registered engine commands:', engineCommands); 585 - }; 586 - 587 - /** 588 - * Unregister engine commands 589 - */ 590 - const unregisterEngineCommands = () => { 591 - for (const name of engineCommands) { 592 - api.commands.unregister(name); 593 - } 594 - engineCommands = []; 595 - }; 596 - 597 - /** 598 - * Register all commands (static + per-engine) 599 - */ 600 - const initCommands = () => { 601 - // Register static commands 602 - staticCommandDefinitions.forEach(cmd => { 603 - api.commands.register(cmd); 604 - registeredCommands.push(cmd.name); 605 - }); 606 - 607 - // Register engine commands 608 - registerEngineCommands(); 609 - 610 - debug && console.log('[ext:websearch] Registered commands:', [...registeredCommands, ...engineCommands]); 611 - }; 612 - 613 - /** 614 - * Unregister all commands 615 - */ 616 - const uninitCommands = () => { 617 - registeredCommands.forEach(name => { 618 - api.commands.unregister(name); 619 - }); 620 - registeredCommands = []; 621 - 622 - unregisterEngineCommands(); 623 - 624 - debug && console.log('[ext:websearch] Unregistered commands'); 625 - }; 626 - 627 - // ===== Event Handlers ===== 628 - 629 - /** 630 - * Handle page navigation events for OpenSearch discovery 631 - */ 632 - const handlePageLoaded = async (msg) => { 633 - if (!currentSettings.prefs.enableAutoDiscovery) return; 634 - if (!msg || !msg.url) return; 635 - 636 - // Only discover from http/https pages 637 - if (!msg.url.startsWith('http://') && !msg.url.startsWith('https://')) return; 638 - 639 - // Check if the page has an OpenSearch link 640 - if (msg.opensearchUrl) { 641 - await discoverEngine(msg.opensearchUrl, msg.url); 642 - } 643 - }; 644 - 645 - /** 646 - * Handle suggestion requests from the search UI or command bar 647 - */ 648 - const handleSuggestionRequest = async (msg) => { 649 - if (!msg || !msg.query) return; 650 - 651 - const engineId = msg.engineId || currentSettings.prefs.defaultEngine; 652 - const engine = getEngine(engineId) || getDefaultEngine(); 653 - 654 - // Cancel previous in-flight request 655 - if (currentSuggestionAbortController) { 656 - currentSuggestionAbortController.abort(); 657 - } 658 - currentSuggestionAbortController = new AbortController(); 659 - 660 - const suggestions = await fetchSuggestionsWithCache( 661 - engine, 662 - msg.query, 663 - currentSuggestionAbortController.signal 664 - ); 665 - 666 - // Publish suggestions response via BroadcastChannel 667 - emitChannel('websearch:suggestions', { 668 - query: msg.query, 669 - engineId: engine.id, 670 - engineName: engine.name, 671 - suggestions 672 - }); 673 - }; 674 - 675 - /** 676 - * Handle engine management requests 677 - */ 678 - const handleEngineRequest = async (msg) => { 679 - if (!msg || !msg.action) return; 680 - 681 - switch (msg.action) { 682 - case 'list': 683 - emitChannel('websearch:engines-list', { 684 - engines: engines.map(e => ({ 685 - id: e.id, 686 - name: e.name, 687 - shortName: e.shortName, 688 - description: e.description, 689 - searchUrl: e.searchUrl, 690 - suggestUrl: e.suggestUrl, 691 - iconUrl: e.iconUrl, 692 - isBuiltIn: e.isBuiltIn, 693 - enabled: e.enabled !== false, 694 - isDefault: e.id === currentSettings.prefs.defaultEngine 695 - })), 696 - defaultEngineId: currentSettings.prefs.defaultEngine 697 - }); 698 - break; 699 - 700 - case 'add': 701 - if (msg.engine) { 702 - await addEngine(msg.engine); 703 - } 704 - break; 705 - 706 - case 'remove': 707 - if (msg.engineId) { 708 - await removeEngine(msg.engineId); 709 - } 710 - break; 711 - 712 - case 'set-default': 713 - if (msg.engineId && getEngine(msg.engineId)) { 714 - currentSettings.prefs.defaultEngine = msg.engineId; 715 - await saveSettings(currentSettings); 716 - api.pubsub.publish('websearch:settings-changed', currentSettings); 717 - } 718 - break; 719 - 720 - case 'toggle': 721 - if (msg.engineId) { 722 - const engine = getEngine(msg.engineId); 723 - if (engine) { 724 - engine.enabled = msg.enabled !== undefined ? msg.enabled : !(engine.enabled !== false); 725 - await saveEngines(); 726 - // Re-register commands with updated enabled state 727 - unregisterEngineCommands(); 728 - registerEngineCommands(); 729 - } 730 - } 731 - break; 732 - } 733 - }; 734 - 735 - // ===== Lifecycle ===== 736 - 737 - const init = async () => { 738 - // Load settings from datastore 739 - currentSettings = await loadSettings(); 740 - 741 - // Load custom engines from datastore 742 - await loadEngines(); 743 - 744 - // Register commands (after settings/engines are loaded so we register once) 745 - initCommands(); 746 - 747 - // Listen for page load events for OpenSearch discovery 748 - api.pubsub.subscribe('page:loaded', handlePageLoaded); 749 - 750 - // Listen for suggestion requests via BroadcastChannel 751 - onChannel('websearch:request-suggestions', handleSuggestionRequest); 752 - 753 - // Listen for engine management requests via BroadcastChannel 754 - onChannel('websearch:engine-request', handleEngineRequest); 755 - 756 - // Listen for window close events 757 - api.pubsub.subscribe('window:closed', async (msg) => { 758 - const closedWindowId = msg?.id; 759 - if (closedWindowId === searchWindowId) { 760 - debug && console.log('[ext:websearch] Search window closed'); 761 - searchWindowId = null; 762 - } 763 - }); 764 - 765 - // Listen for settings changes to hot-reload 766 - api.pubsub.subscribe('websearch:settings-changed', async () => { 767 - debug && console.log('[ext:websearch] settings changed, reinitializing'); 768 - uninit(); 769 - currentSettings = await loadSettings(); 770 - await loadEngines(); 771 - initCommands(); 772 - }); 773 - 774 - // Listen for settings updates from Settings UI 775 - api.pubsub.subscribe('websearch:settings-update', async (msg) => { 776 - debug && console.log('[ext:websearch] settings-update received:', msg); 777 - 778 - try { 779 - if (msg.data) { 780 - currentSettings = { 781 - prefs: { ...currentSettings.prefs, ...(msg.data.prefs || {}) } 782 - }; 783 - } else if (msg.key === 'prefs' && msg.path) { 784 - const field = msg.path.split('.')[1]; 785 - if (field) { 786 - currentSettings.prefs = { ...currentSettings.prefs, [field]: msg.value }; 787 - } 788 - } 789 - 790 - await saveSettings(currentSettings); 791 - 792 - // Clear suggestion cache if suggestions were toggled off 793 - if (!currentSettings.prefs.enableSuggestions) { 794 - clearSuggestionCache(); 795 - } 796 - 797 - uninit(); 798 - await loadEngines(); 799 - initCommands(); 800 - 801 - api.pubsub.publish('websearch:settings-changed', currentSettings); 802 - } catch (err) { 803 - console.error('[ext:websearch] settings-update error:', err); 804 - } 805 - }); 806 - }; 807 - 808 - const uninit = () => { 809 - uninitCommands(); 810 - 811 - // Cancel any in-flight suggestion request 812 - if (currentSuggestionAbortController) { 813 - currentSuggestionAbortController.abort(); 814 - currentSuggestionAbortController = null; 815 - } 816 - }; 817 - 818 - export default { 819 - defaults, 820 - id, 821 - init, 822 - uninit, 823 - labels, 824 - schemas, 825 - storageKeys, 826 - // Expose for testing 827 - builtinEngines, 828 - getEngine, 829 - getDefaultEngine, 830 - getEnabledEngines, 831 - buildSearchUrl, 832 - parseOpenSearchXml, 833 - };
+5 -3
features/websearch/home.js
··· 457 457 } 458 458 } 459 459 460 - // 5. Last resort: if typed contains a space, take everything after the first space 461 - // (handles case where name field might be missing) 462 - if (ctx.typed) { 460 + // 5. Last resort: if name is unknown AND typed contains a space, take everything 461 + // after the first space. Skipped when ctx.name is set, because a multi-word 462 + // command name (e.g. "web search") with no query would otherwise bleed the 463 + // second word into the query. 464 + if (!ctx.name && ctx.typed) { 463 465 const spaceIdx = ctx.typed.indexOf(' '); 464 466 if (spaceIdx > 0) { 465 467 const rest = ctx.typed.slice(spaceIdx + 1).trim();
+51
tests/desktop-serial/websearch-cmd.spec.ts
··· 277 277 } 278 278 }); 279 279 280 + test('"web search" with no query shows search UI, not a search results URL', async () => { 281 + // Regression: extractSearchQuery's last-resort fallback used to grab the 282 + // word after the first space when the typed text equalled the (multi-word) 283 + // command name. Result: typing "web search" with no query would search the 284 + // default engine for the literal string "search" — opening e.g. 285 + // kagi.com/search?q=search instead of the websearch home tile. 286 + await ensureWebsearchLoaded(sharedBgWindow); 287 + 288 + // Snapshot the URLs of windows that exist before invocation, so we can 289 + // detect any *new* content window that the buggy path would open. 290 + const before = await sharedBgWindow.evaluate(async () => { 291 + const result = await (window as any).app.window.list({ includeInternal: true }); 292 + return result?.success ? (result.windows || []).map((w: any) => w.id) : []; 293 + }); 294 + 295 + const { cmdWindow, windowId } = await openCmdPanel(sharedBgWindow, sharedApp); 296 + 297 + try { 298 + await cmdWindow.fill('input', 'web search'); 299 + await cmdWindow.keyboard.press('Enter'); 300 + 301 + const finalState = await waitForCmdNotExecuting(cmdWindow, 20000); 302 + expect(finalState).not.toBe('TIMEOUT'); 303 + expect(finalState).not.toBe('ERROR'); 304 + 305 + // After the command settles, a websearch home tile should be visible 306 + // and no new content window pointing at a search engine results page 307 + // should have appeared. 308 + await sleep(500); 309 + const after = await sharedBgWindow.evaluate(async () => { 310 + const result = await (window as any).app.window.list({ includeInternal: true }); 311 + return result?.success ? (result.windows || []) : []; 312 + }); 313 + 314 + const newWindows = after.filter((w: any) => !before.includes(w.id)); 315 + const newSearchResultWindow = newWindows.find((w: any) => { 316 + const url = w.url || ''; 317 + // Anything that looks like a search engine results URL (q=, search?) 318 + return /[?&]q=|\/search\?/.test(url); 319 + }); 320 + expect(newSearchResultWindow, `Unexpected results window opened: ${newSearchResultWindow?.url}`).toBeUndefined(); 321 + 322 + const websearchHomeVisible = after.some( 323 + (w: any) => (w.url || '').includes('peek://websearch/home.html'), 324 + ); 325 + expect(websearchHomeVisible).toBe(true); 326 + } finally { 327 + await closeCmdPanel(sharedBgWindow, windowId); 328 + } 329 + }); 330 + 280 331 test('direct pubsub kagi execution works (control test)', async () => { 281 332 // This is the control — same command via direct pubsub should work. 282 333 // If the UI test above fails but this passes, the bug is in the proxy flow.