PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / ai-assistant.js

ai-assistant.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.5, at assets/js/ai-assistant.js

2,451 lines 101.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 function getWpHooks() {
4 const hooks = window.wp?.hooks;
5 if (!hooks) {
6 throw new Error(
7 "[desktop-mode] `window.wp.hooks` is not available. The plugin declares `wp-hooks` as a script dependency; if you are seeing this error, verify the enqueue order."
8 );
9 }
10 return hooks;
11 }
12 function applyFilters(hookName, value, ...args) {
13 return getWpHooks().applyFilters(hookName, value, ...args);
14 }
15 function doAction(hookName, ...args) {
16 getWpHooks().doAction(hookName, ...args);
17 }
18 const HOOKS = {
19 /** Action, fires once after shell boot; plugins register here. */
20 INIT: "desktop-mode.init",
21 /** Filter, receives the wallpaper registry array. */
22 WALLPAPERS: "desktop-mode.wallpapers",
23 /** Filter, receives the unfocused-window effect registry array. */
24 UNFOCUS_EFFECTS: "desktop-mode.unfocus-effects",
25 /** Action before a canvas wallpaper mounts. */
26 WALLPAPER_MOUNTING: "desktop-mode.wallpaper.mounting",
27 /** Action after a canvas wallpaper mounts successfully. */
28 WALLPAPER_MOUNTED: "desktop-mode.wallpaper.mounted",
29 /** Action before a canvas wallpaper tears down. */
30 WALLPAPER_UNMOUNTING: "desktop-mode.wallpaper.unmounting",
31 /** Action when a canvas wallpaper's mount throws / rejects. */
32 WALLPAPER_MOUNT_FAILED: "desktop-mode.wallpaper.mount-failed",
33 /** Action mirroring document.visibilitychange for active canvas wallpapers. */
34 WALLPAPER_VISIBILITY: "desktop-mode.wallpaper.visibility",
35 /**
36 * Filter, receives a wallpaper's preview params (seeded from the
37 * def's `previewParams`) before its `renderPreview` runs in the OS
38 * Settings picker. Args: `( params, wallpaperId )`.
39 */
40 WALLPAPER_PREVIEW_PARAMS: "desktop-mode.wallpaper.preview-params",
41 /**
42 * Action, fires after a wallpaper's persisted settings change (the
43 * user edited them through the wallpaper's config dialog in OS
44 * Settings). Payload: `{ id, settings }` — the wallpaper id and the
45 * full post-merge settings object. A mounted wallpaper subscribes to
46 * live-apply changes without a remount.
47 *
48 * @since 0.9.5
49 */
50 WALLPAPER_SETTINGS_CHANGED: "desktop-mode.wallpaper.settings-changed",
51 // ------------------------------------------------------------------
52 // Observability — iframe errors, iframe network, shell-side errors,
53 // monitor entry aggregation. Designed for dashboard / debug widget
54 // plugins that want genuine admin observability (Gutenberg save
55 // failures, admin-ajax 500s, plugin exceptions) rather than just the
56 // shell's own console-error surface.
57 // ------------------------------------------------------------------
58 /**
59 * Action, fires once per iframe when the chromeless bridge
60 * script has finished wiring its message listeners. Payload:
61 * `{ windowId: string }`. Subscribers get a reliable "safe to
62 * talk to this iframe" signal — the browser's native `load`
63 * event fires before our bridge attaches, so messages sent on
64 * `load` can be dropped on the floor. Use this instead when
65 * timing matters (first-focus dispatch, auto-fill handshakes).
66 *
67 * @since 0.5.0
68 */
69 IFRAME_READY: "desktop-mode.iframe.ready",
70 /**
71 * Action, fires when a chromeless iframe's `error` or
72 * `unhandledrejection` handler catches an exception. Payload: `{
73 * windowId: string, kind: 'error' | 'unhandledrejection', message:
74 * string, filename: string | null, lineno: number | null, colno:
75 * number | null, stack: string | null }`. Origin-filtered at the
76 * parent shell; cross-origin iframe errors never reach here.
77 */
78 IFRAME_ERROR: "desktop-mode.iframe.error",
79 /**
80 * Action, fires when a `fetch` or `XMLHttpRequest` inside a
81 * chromeless iframe completes (success OR failure). Payload: `{
82 * windowId: string, method: string, url: string, status: number,
83 * duration: number, failed: boolean }`. Subscribers get a faithful
84 * view of admin-ajax + REST calls that previously never left the
85 * iframe boundary. `status === 0` indicates a network failure with
86 * no response received.
87 */
88 IFRAME_NETWORK_COMPLETED: "desktop-mode.iframe.network-completed",
89 /**
90 * Action, fires when one of the shell's own try/catch barriers
91 * catches an exception. Payload: `{ scope:
92 * 'widget-mount' | 'widget-teardown' | 'window-open' | 'wallpaper-mount' |
93 * 'wallpaper-teardown' | 'session-save' | 'menu-refresh' | string,
94 * id?: string, error: unknown }`. Paired with the existing
95 * `console.error` calls — a monitor widget can surface these as
96 * first-class entries.
97 */
98 SHELL_ERROR: "desktop-mode.shell.error",
99 /**
100 * Action, fires once per `wp.desktop.broadcast()` call with the
101 * fully-resolved `{ topic, payload }` detail. Lets plugins log,
102 * mirror, or augment broadcast traffic without subscribing for
103 * every individual topic.
104 */
105 BROADCAST: "desktop-mode.broadcast",
106 /**
107 * Filter, applies to a `MonitorEntry` before a monitor widget
108 * renders it. Plugins can mutate the entry (rewrite the message,
109 * add `extra` fields) or return `null` to suppress it. Used by
110 * monitor widgets to converge every plugin on the same shape —
111 * see `MonitorEntry` in `src/types.ts`.
112 */
113 MONITOR_ENTRY: "desktop-mode.monitor.entry",
114 /**
115 * Filter, applies to the list of "solid" surfaces wallpapers
116 * should consider for collision / accumulation effects (snow
117 * piling, leaves settling, rain splash). Seeded by the shell
118 * with: every visible (non-minimized) window's top edge; the
119 * desktop-area floor; the dock's outward-facing edge; and every
120 * mounted widget card's top edge.
121 *
122 * Plugins that own their own DOM (e.g. floating pickers,
123 * custom overlays) can push additional surfaces so snow
124 * accumulates on them too.
125 *
126 * Each entry is a `WallpaperSurface` — see
127 * `src/wallpapers/surfaces.ts` for the shape. Rects are in
128 * viewport coordinates (clientX / clientY), matching what a
129 * canvas mounted inside `#desktop-mode-wallpaper` reads.
130 */
131 WALLPAPER_SURFACES: "desktop-mode.wallpaper.surfaces",
132 // ------------------------------------------------------------------
133 // Window lifecycle actions. All payloads share a `windowId: string`
134 // field; additional fields are documented per-hook in the JS
135 // reference. These mirror the existing `desktop-mode-window-*`
136 // CustomEvents but ship under the hook bus so plugins can use one
137 // idiomatic API for everything the shell emits.
138 // ------------------------------------------------------------------
139 /**
140 * Filter, last call before a window's resolved geometry (x, y,
141 * width, height, initialState) is baked into the `WindowConfig`
142 * passed to the `Window` constructor. Lets a plugin override
143 * default placement for windows it owns, snap restored bounds to
144 * a different region, or force a particular initial state.
145 *
146 * Signature:
147 *
148 * ( geometry: ResolvedWindowGeometry, ctx: WindowGeometryContext )
149 * => ResolvedWindowGeometry
150 *
151 * Where `ResolvedWindowGeometry = { x, y, width, height, state? }`
152 * and `ctx = { windowId, baseId, hasSavedGeometry, callerPinned,
153 * desktopRect }`.
154 *
155 * - `hasSavedGeometry` is `true` when the user previously
156 * dragged or resized this window and the resolved geometry
157 * includes those restored values. Plugins that want to
158 * "leave the user's saved layout alone" should bail when
159 * this is true.
160 * - `callerPinned` is `true` when the caller of `manager.open()`
161 * passed at least one of `{ x, y, width, height, initialState }`
162 * explicitly. For NATIVE windows this is usually true (the
163 * framework's native-window opener passes the registry's
164 * declared dimensions); for admin-page iframe windows opened
165 * from the dock this is usually false. The filter is free to
166 * override registry defaults — `callerPinned: true` does NOT
167 * mean "leave it alone."
168 *
169 * The shell re-clamps `width`/`height` to the registered
170 * `minWidth`/`minHeight` after the filter returns — a buggy
171 * filter cannot ship a sub-minimum window. `x` and `y` are
172 * NOT re-clamped to the desktop rect after the filter (plugins
173 * sometimes want to place windows partially off-screen for
174 * deliberate stylistic reasons); the filter is responsible for
175 * its own viewport math when it cares.
176 *
177 * Companion of `desktop_mode_register_window` server-side
178 * defaults — runs every time a window opens, not just at
179 * registration.
180 *
181 * @since 0.8.6
182 */
183 WINDOW_GEOMETRY: "desktop-mode.window.geometry",
184 /** Action, fires when a window is added to the stack. */
185 WINDOW_OPENED: "desktop-mode.window.opened",
186 /**
187 * Action, fires when a window's body enters the loading state — at
188 * construction (every window starts loading) and whenever a plugin
189 * calls {@link NativeRenderContext.window.markLoading} or
190 * `Window.markContentLoading()` mid-life. Payload: `{ windowId }`.
191 *
192 * The shell shows a `<wpd-spinner>` overlay while the window is in
193 * the loading state and fades content in on the loaded transition.
194 * Subscribe to this hook (or to {@link WINDOW_CONTENT_LOADED}) when
195 * you need to react to either edge — analytics, instrumentation,
196 * decorating the spinner with a per-window message.
197 *
198 * Edge-triggered: idempotent calls don't re-fire. The matching
199 * `desktop-mode-window-content-loading` CustomEvent dispatches on
200 * `document` with the same payload.
201 *
202 * @since 0.6.0
203 */
204 WINDOW_CONTENT_LOADING: "desktop-mode.window.content-loading",
205 /**
206 * Action, fires when a window's body content becomes ready — for
207 * iframe windows the moment the chromeless bridge announces
208 * `desktop-mode-ready`, for native windows after the user's
209 * `render( body )` callback (or its returned promise) resolves, and
210 * whenever a plugin calls {@link NativeRenderContext.window.markReady}
211 * or `Window.markContentLoaded()` mid-life. Payload: `{ windowId }`.
212 *
213 * The unified "window content is ready" signal across both render
214 * strategies — use this instead of branching on iframe vs. native.
215 * Iframe-only consumers can still subscribe to {@link IFRAME_READY},
216 * which fires alongside this hook for iframe windows. The shell
217 * removes the loading overlay and fades the content in on this
218 * transition.
219 *
220 * Edge-triggered: only fires on a loading → ready transition.
221 * The matching `desktop-mode-window-content-loaded` CustomEvent
222 * dispatches on `document` with the same payload.
223 *
224 * @since 0.6.0
225 */
226 WINDOW_CONTENT_LOADED: "desktop-mode.window.content-loaded",
227 /**
228 * Filter, applied to the loading-overlay HTMLElement just after
229 * the shell paints its default `<wpd-spinner>` and after any
230 * per-window inline customization (`config.loading.render`)
231 * runs. Receives the overlay element; context: `{ windowId,
232 * config }`. Plugins may mutate the element (e.g.
233 * `host.replaceChildren( myBrandedLoader )` to swap out the
234 * default entirely, or `host.querySelector('wpd-spinner')!.
235 * setAttribute('preset', 'comet')` to retune the spinner) or
236 * return a different element to replace the overlay wholesale.
237 *
238 * Use cases: a brand-skin plugin that overrides every window's
239 * spinner with its own logo; a status-bar plugin that adds
240 * "Loading… 47% — fetching posts" text; an A/B-test framework
241 * that swaps the loader during an experiment.
242 *
243 * Resolution order for the loading overlay:
244 * 1. Default content (`<wpd-spinner>`) is painted.
245 * 2. Per-window `config.loading.render( host, ctx )` runs.
246 * 3. This filter runs.
247 * 4. The result is appended to the window body.
248 *
249 * @since 0.6.0
250 */
251 WINDOW_LOADING_OVERLAY: "desktop-mode.window.loading-overlay",
252 /**
253 * Action, fires when `manager.open(...)` is called for a baseId
254 * whose window already exists on the active desktop. This is the
255 * unambiguous "user requested to open this window again" signal
256 * — distinct from focus changes (which double-fire on alt-tab and
257 * skip when already focused) and from `WINDOW_OPENED` (which only
258 * fires on first creation). Payload:
259 * `{ windowId: string, baseId: string, wasMinimized: boolean }`.
260 *
261 * Plugins that hold per-window state (e.g. the code-editor's
262 * active file) should listen here to re-orient the existing
263 * window's content to whatever the caller wants to show — the
264 * open-window call is synchronous, so any state the caller sets
265 * BEFORE invoking `openWindow` is already in place when this
266 * fires.
267 */
268 WINDOW_REOPENED: "desktop-mode.window.reopened",
269 /**
270 * Action, fires BEFORE the window's element is detached from the
271 * DOM but AFTER the manager has already removed it from the stack.
272 * Payload: `{ windowId: string, element: HTMLElement }`.
273 *
274 * Use this for cleanup that needs a reference to the live
275 * element (removing anchored snow, wallpaper particles pinned to
276 * window tops, measurement caches keyed by element). `WINDOW_CLOSED`
277 * fires immediately after and only carries the id, which means
278 * subscribers would otherwise have to re-query the DOM — by then
279 * the element is gone, so they can't match at all.
280 */
281 WINDOW_CLOSING: "desktop-mode.window.closing",
282 /** Action, fires when a window is removed from the stack. */
283 WINDOW_CLOSED: "desktop-mode.window.closed",
284 /** Action, fires when focus changes to a different window. */
285 WINDOW_FOCUSED: "desktop-mode.window.focused",
286 /**
287 * Action, fires for the window that LOST focus when another
288 * window takes over. Symmetric counterpart to
289 * `WINDOW_FOCUSED`. Payload: `{ windowId: string, focusedTo:
290 * string | null }` — `focusedTo` identifies the new top of
291 * the stack so blur subscribers can ignore alt-tabs to a
292 * sibling they own.
293 *
294 * No-op when there's no previously-focused window (initial
295 * boot, all-windows-closed). Manager fires this BEFORE
296 * `WINDOW_FOCUSED` so subscribers see "blur old, focus new"
297 * in deterministic order.
298 *
299 * @since 0.5.5
300 */
301 WINDOW_BLURRED: "desktop-mode.window.blurred",
302 /**
303 * Action, fires when a window is minimized. Payload:
304 * `{ windowId: string, element: HTMLElement }`.
305 *
306 * The element ride-along matches {@link WINDOW_CLOSING}'s shape so
307 * wallpaper plugins anchored to window tops (snow, leaves, rain
308 * splash) can match stuck particles by element identity and run
309 * their teardown — minimized windows render at `opacity: 0` so
310 * `offsetParent === null` checks miss them.
311 */
312 WINDOW_MINIMIZED: "desktop-mode.window.minimized",
313 /**
314 * Action, fires when a window is restored from minimized. Payload:
315 * `{ windowId: string, element: HTMLElement }`.
316 */
317 WINDOW_RESTORED: "desktop-mode.window.restored",
318 /**
319 * Action, fires when a window is maximized (fills desktop area).
320 * Payload: `{ windowId: string, element: HTMLElement }`.
321 */
322 WINDOW_MAXIMIZED: "desktop-mode.window.maximized",
323 /**
324 * Action, fires when a window exits maximized state. Payload:
325 * `{ windowId: string, element: HTMLElement }`.
326 */
327 WINDOW_UNMAXIMIZED: "desktop-mode.window.unmaximized",
328 /**
329 * Action, fires when a window enters fullscreen / focus mode.
330 * Payload: `{ windowId: string, element: HTMLElement }`.
331 */
332 WINDOW_FULLSCREEN_ENTERED: "desktop-mode.window.fullscreen-entered",
333 /**
334 * Action, fires when a window exits fullscreen / focus mode.
335 * Payload: `{ windowId: string, element: HTMLElement }`.
336 */
337 WINDOW_FULLSCREEN_EXITED: "desktop-mode.window.fullscreen-exited",
338 /**
339 * Filter, decides whether a fullscreen ("focus mode") window
340 * should auto-exit when focus moves to a different window.
341 *
342 * Default is `true` so a newly-focused window is never silently
343 * occluded by a fullscreen one (its `z-index` sits above all
344 * other windows). Plugins whose fullscreen surface is meant to
345 * persist across focus changes — slideshows, video players,
346 * immersive games — can return `false` to keep their window
347 * fullscreen.
348 *
349 * Signature:
350 *
351 * ( shouldExit: boolean, ctx: {
352 * windowId: string, // the fullscreen window
353 * focusedTo: string, // the window gaining focus
354 * } ) => boolean
355 *
356 * @since 0.8.6
357 */
358 WINDOW_AUTO_EXIT_FULLSCREEN: "desktop-mode.window.auto-exit-fullscreen",
359 /**
360 * Filter, decides whether the window under the cursor is raised
361 * (focused) after a short hover dwell during a drag — any drag,
362 * whatever its source: a shell DragManager session, a
363 * cross-iframe bridge drag, an OS file, or an arbitrary native
364 * HTML5 drag.
365 *
366 * Default is `true`: dragging a payload over a background window
367 * and resting there for ~250 ms brings it forward, so the user
368 * can see the drop target they're aiming at (macOS spring-loading
369 * style). Plugins whose windows must never steal z-order during a
370 * drag — pinned reference panels, HUD/palette windows — can
371 * return `false` for their window id.
372 *
373 * Signature:
374 *
375 * ( shouldFocus: boolean, ctx: {
376 * windowId: string, // the hovered window
377 * payloadType: string, // DragManager payload `type`,
378 * // bridge payload `kind`,
379 * // 'os-file', or 'external'
380 * } ) => boolean
381 *
382 * @since 0.9.4
383 */
384 WINDOW_FOCUS_ON_DRAG_HOVER: "desktop-mode.window.focus-on-drag-hover",
385 /**
386 * Action, fires at most once per animation frame during an
387 * active drag or resize with the live geometry. Payload: `{
388 * windowId: string, x: number, y: number, width: number,
389 * height: number, state: WindowState, phase: 'drag' | 'resize' }`.
390 *
391 * Intended for per-frame collision-aware wallpapers (snow piling
392 * on window tops, rain splash on edges) that would otherwise
393 * poll `getBoundingClientRect` every rAF. Coalesced via
394 * `requestAnimationFrame` so a pointermove storm collapses to
395 * one fire per paint — matches the cadence a wallpaper's own
396 * ticker runs at.
397 *
398 * NOT fired at drag/resize end — `WINDOW_DRAG_END` /
399 * `WINDOW_RESIZE_END` handle the settled geometry. Subscribers
400 * that only want the final position should listen to those
401 * instead.
402 */
403 WINDOW_BOUNDS_CHANGED: "desktop-mode.window.bounds-changed",
404 /** Action, fires at drag-end with the final `{ x, y }` position. */
405 WINDOW_MOVED: "desktop-mode.window.moved",
406 /** Action, fires at resize-end with the final `{ width, height }`. */
407 WINDOW_RESIZED: "desktop-mode.window.resized",
408 /** Action, fires when title-bar drag begins. */
409 WINDOW_DRAG_START: "desktop-mode.window.drag-start",
410 /** Action, fires when title-bar drag ends. Payload mirrors WINDOW_MOVED. */
411 WINDOW_DRAG_END: "desktop-mode.window.drag-end",
412 /** Action, fires when the resize handle is first pressed. */
413 WINDOW_RESIZE_START: "desktop-mode.window.resize-start",
414 /** Action, fires when resize completes. Payload mirrors WINDOW_RESIZED. */
415 WINDOW_RESIZE_END: "desktop-mode.window.resize-end",
416 /** Action, fires when the user "detaches" a window to a classic tab. */
417 WINDOW_DETACHED: "desktop-mode.window.detached",
418 /**
419 * Action, fires when the user clicks the title-bar reload button
420 * on an iframe-backed window. Payload: `{ windowId: string, url:
421 * string }` where `url` is the URL being reloaded (the active
422 * primary or external sub-tab). Subscribers can use this to
423 * invalidate their own cache, force a save before navigation,
424 * track usage as a UX signal, or sync state across companion
425 * surfaces. Native windows do not fire this — they own their
426 * DOM directly and the reload button doesn't apply.
427 */
428 WINDOW_RELOADED: "desktop-mode.window.reloaded",
429 /** Action, fires when iframe title updates change the window title. */
430 WINDOW_TITLE_CHANGED: "desktop-mode.window.title-changed",
431 /**
432 * Action, fires when a window's `setHighlight()` mode changes.
433 * Payload: `{ windowId: string, mode: 'preview' | 'persistent' | null,
434 * color?: string }`. Lets onboarding / guidance / drag-bridge
435 * plugins react when another module flagged one of their
436 * windows as the focus of a multi-step interaction without
437 * having to observe DOM mutations.
438 *
439 * @since 0.6.0
440 */
441 WINDOW_HIGHLIGHT_CHANGED: "desktop-mode.window.highlight-changed",
442 /**
443 * Action, fires when a window's body element's dimensions
444 * change — mount, user resize, viewport reflow. Payload: `{
445 * windowId: string, width: number, height: number }`. Body
446 * dimensions exclude the title bar + tab strip, matching what a
447 * canvas or layout engine inside the body would measure.
448 */
449 WINDOW_BODY_RESIZED: "desktop-mode.window.body-resized",
450 // ------------------------------------------------------------------
451 // Native-window lifecycle. These fire ONLY for windows constructed
452 // with `native: true` — iframe windows have no render phase to
453 // intercept. Use them to wrap / instrument / cancel the paint of
454 // plugin-contributed native windows (the Calculator, Jorvy, custom
455 // native launchers).
456 // ------------------------------------------------------------------
457 /**
458 * Filter, applied to the body element a native window will render
459 * into, just BEFORE the user's `render( body )` callback runs.
460 * Payload: the `HTMLElement`; context: `{ windowId, config }`.
461 *
462 * Return the same element (or a wrapper) to intercept. Subscribers
463 * commonly use this to inject a consistent shell (padding,
464 * background, decorative chrome) around every native window
465 * without every plugin re-implementing the pattern.
466 */
467 NATIVE_WINDOW_BEFORE_RENDER: "desktop-mode.native-window.before-render",
468 /**
469 * Action, fires AFTER a native window's `render( body )` callback
470 * returns. Payload: `{ windowId, body, config }`. Observability
471 * hook — analytics / auto-focus / post-render measurement.
472 */
473 NATIVE_WINDOW_AFTER_RENDER: "desktop-mode.native-window.after-render",
474 /**
475 * Filter, applied when a native window is about to start its
476 * close animation. Return `false` to CANCEL the close — the
477 * window stays open. Payload: `true`; context: `{ windowId,
478 * config }`. Any non-`false` return (including `undefined`) lets
479 * the close proceed.
480 *
481 * Intended for "unsaved changes" guards: a calculator with a
482 * pending operation can prompt the user and abort the close
483 * mid-flight. Does NOT apply to iframe windows — their close is
484 * driven by browser navigation patterns the shell doesn't own.
485 */
486 NATIVE_WINDOW_BEFORE_CLOSE: "desktop-mode.native-window.before-close",
487 // ------------------------------------------------------------------
488 // Window-chrome customization framework. Plugins drive per-window
489 // appearance (theme, controls, slots, full chrome render) through
490 // the `wp.desktop.registerWindow*` registries; these hooks expose
491 // every resolution step so plugins can mutate or observe the
492 // chrome pipeline without owning a registration.
493 //
494 // Layers 1-3 (theme, controls, slots) are Stable. Layer 4 (chrome
495 // render) is Experimental — `WINDOW_CHROME_RENDER` may change.
496 // ------------------------------------------------------------------
497 /**
498 * Filter, applied to the resolved CSS-variable map for a window.
499 * Receives `Record< string, string >`; context: `{ windowId,
500 * config }`. Plugins return a mutated map to override or augment
501 * the per-window theme tokens — e.g. tint every Gutenberg
502 * window's title bar to brand colour.
503 *
504 * Stable since 0.6.0.
505 */
506 WINDOW_CHROME_THEME: "desktop-mode.window.chrome.theme",
507 /**
508 * Filter, applied to the resolved control list for a window.
509 * Receives `WindowControlDef[]`; context: `{ windowId, config,
510 * placement: 'left' | 'right' | 'controls' }`. Plugins return a
511 * mutated array to reorder, hide, or inject controls per-window.
512 *
513 * Stable since 0.6.0.
514 */
515 WINDOW_CHROME_CONTROLS: "desktop-mode.window.chrome.controls",
516 /**
517 * Filter, applied per slot when the chrome paints. Receives the
518 * slot host element; context: `{ windowId, slot, config }`.
519 * Plugins can mutate `host` (append decorative children, set
520 * inline styles) without owning a `WindowSlotDef` registration.
521 * The shell never reads the return value — this is an action-
522 * shaped filter so existing `addFilter` plumbing applies.
523 *
524 * Stable since 0.6.0.
525 */
526 WINDOW_CHROME_SLOT: "desktop-mode.window.chrome.slot",
527 /**
528 * Filter, applied to the chrome id selected for a window.
529 * Receives the resolved id (defaults to `'core/standard'`);
530 * context: `{ windowId, config }`. Returning a different id
531 * swaps the chrome registration. **Experimental** — chrome
532 * render contract may change.
533 *
534 * @since 0.6.0
535 */
536 WINDOW_CHROME_RENDER: "desktop-mode.window.chrome.render",
537 /**
538 * Action, fires after a window chrome layer has been mounted /
539 * remounted. Payload: `{ windowId, layer: 'chrome' | 'controls'
540 * | 'slots', chromeId? }` — `chromeId` is present only when
541 * `layer` is `'chrome'`. Subscribers can post-decorate the
542 * chrome (attach observers, anchor pickers).
543 *
544 * @since 0.6.0
545 */
546 WINDOW_CHROME_APPLIED: "desktop-mode.window.chrome.applied",
547 /**
548 * Action, fires after a window's theme tokens are applied to its
549 * outer element. Payload: `{ windowId, themeId, tokens }`. Lets
550 * plugins react to theme changes without diffing CSS variables.
551 *
552 * @since 0.6.0
553 */
554 WINDOW_CHROME_THEME_CHANGED: "desktop-mode.window.chrome.theme-changed",
555 /**
556 * Action, fires when a user clicks a desktop icon (a shortcut
557 * tile registered server-side via `desktop_mode_register_icon()`
558 * and rendered on the wallpaper). Payload: `{ id: string,
559 * target: 'window' | 'url' }`. Fires BEFORE the default open
560 * action — plugins cannot cancel the open from this hook, but
561 * can use it to track click-throughs or augment behaviour (e.g.
562 * play a sound, surface a confirmation toast).
563 *
564 * @since 0.5.0
565 */
566 DESKTOP_ICON_CLICKED: "desktop-mode.desktop-icon.clicked",
567 /**
568 * Action, fires after the wallpaper icon grid is rendered or
569 * re-rendered. Payload:
570 *
571 * {
572 * ids: string[]; // paint order
573 * container: HTMLElement; // <div class="desktop-mode-icons">
574 * tiles: ReadonlyMap<string, HTMLElement>; // id → tile <button>
575 * }
576 *
577 * Plugins that decorate icons with surfaces the framework doesn't
578 * natively expose (drag handles, status dots, cursor adornments)
579 * subscribe here so their decorations survive a live menu refresh
580 * that legitimately rebuilds the grid. The `container` and
581 * `tiles` map mirror the {@link DOCK_AFTER_RENDER}
582 * `tileElements` contract — reach into them directly instead of
583 * re-`querySelector`ing the rendered DOM.
584 *
585 * Notification badges have a first-class API since 0.6.0 —
586 * use `wp.desktop.icons.setBadge( id, count )` (and subscribe
587 * to {@link ICON_BADGE_CHANGED}) instead of decorating from
588 * here. The framework persists badge state across rebuilds, so
589 * a plugin that uses the API doesn't need to re-decorate on
590 * every render.
591 *
592 * Suppressed entirely when the rendered DOM is unchanged from
593 * the previous call (the fingerprint short-circuit upstream
594 * skips both the rebuild and this signal). When the icon list
595 * is empty the hook does not fire at all — the previous
596 * container is removed and no new one is appended.
597 *
598 * @since 0.6.0
599 * @since 0.8.6 — `container` + `tiles` added to the payload
600 * (`ids` retained for back-compat).
601 */
602 DESKTOP_ICONS_RENDERED: "desktop-mode.desktop-icons.rendered",
603 /**
604 * Action, fires whenever the badge count on a desktop icon
605 * changes. Payload: `{ iconId: string, count: number,
606 * previousCount: number }`. Symmetric to {@link DOCK_ITEM_APPENDED}
607 * and the dock/taskbar `wpd-dock-item-badge-changed` CustomEvent
608 * — the icon rail's lifecycle hook for badge transitions.
609 *
610 * Mirrors `desktop-mode/badge-changed` on the activity bus with
611 * `rail: 'icon'`. Subscribe to whichever surface fits — the
612 * activity channel composes across rails for global widgets,
613 * this hook fires only for icon-rail badges with the previous
614 * count carried alongside for delta-aware consumers.
615 *
616 * @since 0.6.0
617 */
618 ICON_BADGE_CHANGED: "desktop-mode.icon.badge-changed",
619 // ------------------------------------------------------------------
620 // Cross-plugin composition.
621 // ------------------------------------------------------------------
622 /**
623 * Action, fires ONCE after every shell-shipped `<wpd-*>` custom
624 * element has registered with `customElements`. Payload: `{
625 * tags: string[] }` — the list of registered tag names. Plugins
626 * that need to defer work until the component registry is
627 * complete (e.g. hydrate user content that uses these tags)
628 * subscribe here instead of polling `customElements.get()`.
629 */
630 COMPONENTS_REGISTERED: "desktop-mode.components.registered",
631 /**
632 * Action, fires after `wp.desktop.registerSystemTile()` inserts
633 * a tile into the unified dock. Payload: `{ id: string }`. Useful
634 * for plugins that want to decorate tiles they didn't register
635 * themselves — analytics, theming, per-tile badges.
636 */
637 DOCK_ITEM_APPENDED: "desktop-mode.dock.item-appended",
638 /**
639 * Action, fires after a system tile is removed from a rail
640 * via `Dock.removeSystemItem()` (typically the server-driven
641 * native-window-sync path on plugin deactivation). Payload:
642 * `{ id: string, placement: 'dock' | 'taskbar' }`. Symmetric
643 * to {@link DOCK_ITEM_APPENDED}; lets analytics / decorators /
644 * cleanup hooks see the full lifecycle without polling the DOM.
645 *
646 * @since 0.6.0
647 */
648 DOCK_ITEM_REMOVED: "desktop-mode.dock.item-removed",
649 // ------------------------------------------------------------------
650 // Dock decoration hooks — render-pipeline filters and actions the
651 // default `Dock` renderer fires while painting tiles. Plugins
652 // compose decoration (animations, classNames, wrappers, tooltips)
653 // without forking the renderer. Custom rail renderers SHOULD fire
654 // the same hooks for ecosystem compatibility — see
655 // `docs/examples/dock-decoration-hooks.md` for the contract.
656 //
657 // Every detail object carries `{ rail, orientation, dockId,
658 // container }` so a single subscriber can disambiguate when two
659 // rails coexist (Classic layout's left side bar + bottom dock).
660 // `dockId` matches the host element's `id` (e.g. `'desktop-mode-dock'`
661 // or `'desktop-mode-side-dock'`) and is the stable
662 // disambiguator — `rail` and `orientation` are convenience
663 // projections of where the renderer is painting.
664 // ------------------------------------------------------------------
665 /**
666 * Action, fires at the start of every dock paint pass — both the
667 * initial mount and every `replaceItems()` that follows on the
668 * live menu-refresh path. Payload `DockRenderContext`. Use this
669 * to invalidate cached per-render decoration state before the
670 * tiles repopulate.
671 *
672 * @since 0.5.2
673 */
674 DOCK_BEFORE_RENDER: "desktop-mode.dock.before-render",
675 /**
676 * Action, fires once every menu and system tile has landed in
677 * the DOM for a paint pass. Payload `DockRenderContext` plus a
678 * frozen `tileElements: ReadonlyMap<string, HTMLElement>` so a
679 * plugin can decorate every tile in one sweep. Symmetric to
680 * {@link DOCK_BEFORE_RENDER}.
681 *
682 * @since 0.5.2
683 */
684 DOCK_AFTER_RENDER: "desktop-mode.dock.after-render",
685 /**
686 * Filter, runs once per tile while the renderer is composing the
687 * className list. Plugins may add, remove, or reorder classes.
688 * Signature: `( classes: string[], detail: DockTileContext ) =>
689 * string[]`. Order is preserved.
690 *
691 * @since 0.5.2
692 */
693 DOCK_TILE_CLASS: "desktop-mode.dock.tile-class",
694 /**
695 * Filter, runs once per tile after the renderer finishes building
696 * the element but before it lands in the DOM. Return the same
697 * element with mutations, or replace with a wrapper — the shell
698 * inserts whatever you return. Signature:
699 * `( el: HTMLElement, detail: DockTileContext ) => HTMLElement`.
700 *
701 * Returning a different node still has to expose a stable
702 * `[data-menu-slug="<id>"]` (or `[data-system-id="<id>"]`)
703 * descendant for active-state / badge updates to find the tile;
704 * wrap, don't replace.
705 *
706 * @since 0.5.2
707 */
708 DOCK_TILE_ELEMENT: "desktop-mode.dock.tile-element",
709 /**
710 * Action, fires once per tile after it has been inserted into
711 * the DOM. Payload `DockTileContext` plus the resolved `el`. Use
712 * for post-insertion decoration where computed layout matters
713 * (measurements, IntersectionObserver bindings, etc.).
714 *
715 * @since 0.5.2
716 */
717 DOCK_TILE_RENDERED: "desktop-mode.dock.tile-rendered",
718 /**
719 * Filter, resolves the tooltip text for a tile. Runs once at
720 * bind time so the dock doesn't re-filter on every pointerenter.
721 * Signature: `( label: string, detail: DockTileContext ) =>
722 * string`. Return an empty string to suppress the tooltip.
723 *
724 * @since 0.5.2
725 */
726 DOCK_TILE_TOOLTIP: "desktop-mode.dock.tile-tooltip",
727 /**
728 * Filter, resolves the body content of a single hover-peek card.
729 * Runs once per card build (i.e., on every show of the peek for
730 * a multi-instance dock tile that has ≥1 open window). Lets a
731 * plugin render a custom thumbnail, status block, or any other
732 * markup inside the card in place of (or alongside) the default
733 * mini-window styling.
734 *
735 * Signature:
736 * ( body: HTMLElement, detail: DockPeekCardContext ) => HTMLElement
737 *
738 * Where `body` is the `<span class="desktop-mode-dock-peek__card-body">`
739 * element that the peek would otherwise populate with ghosted
740 * content lines. The filter may:
741 * - Mutate `body` in place (e.g., append a custom child) and
742 * return it.
743 * - Empty `body` and append plugin-owned children.
744 * - Return an entirely different element to replace `body`.
745 *
746 * `detail.window` is the live `Window` instance the card represents
747 * — plugins can read `window.config`, call `window.getCurrentUrl()`,
748 * subscribe to lifecycle events, etc. `detail.item` is the dock
749 * item descriptor (id / title / icon / url).
750 *
751 * The filter is invoked under the `applyFilters` namespace
752 * `desktop-mode.dock.peek-card-content`.
753 *
754 * @since 0.6.2
755 */
756 DOCK_PEEK_CARD_CONTENT: "desktop-mode.dock.peek-card-content",
757 /**
758 * Filter, runs once per peek card right before it's appended to
759 * the popover. Receives the fully-built default card (with its
760 * mini-window chrome already populated) and can return either
761 * the same node, a mutated version, or an entirely different
762 * element to replace the card outright. Use this when the
763 * `peek-card-content` body filter isn't enough — e.g., when a
764 * plugin wants to swap the whole card chrome (custom titlebar,
765 * different shape) or wrap the card in a third-party component.
766 *
767 * Signature:
768 * ( card: HTMLElement, detail: DockPeekCardContext ) => HTMLElement
769 *
770 * If a plugin returns a brand-new node, it is responsible for
771 * preserving anything the peek relies on:
772 * - The `desktop-mode-dock-peek__card` class (used by the
773 * fan-out animation timing + hover styles).
774 * - A `click` handler if the card should still focus the
775 * window. The default click handler lives on the original
776 * node — replacing the node loses it.
777 *
778 * @since 0.6.2
779 */
780 DOCK_PEEK_CARD_ELEMENT: "desktop-mode.dock.peek-card-element",
781 // ------------------------------------------------------------------
782 // Overview / Arrange lifecycle actions.
783 //
784 // The "Arrange" admin-bar menu drives two layout algorithms —
785 // Cascade (instantly reposition every window in a staggered
786 // stack) and Overview (zoom-out grid view with click-to-focus).
787 // These hooks surface the state transitions so plugins can
788 // instrument analytics, apply custom transitions, override
789 // thumbnail decorations, etc. All actions; a filter for
790 // mutating the overview layout may be added later if plugins
791 // want to reorder or group thumbnails.
792 // ------------------------------------------------------------------
793 /** Action, fires before the overview enter animation starts. */
794 OVERVIEW_ENTERING: "desktop-mode.overview.entering",
795 /** Action, fires once the overview enter animation has completed. */
796 OVERVIEW_ENTERED: "desktop-mode.overview.entered",
797 /**
798 * Action, fires at the start of the overview-exit animation.
799 * Payload: `{ windowId?: string, reason: 'select' | 'cancel' }` —
800 * `windowId` set when the user clicked a thumbnail (reason
801 * 'select'); omitted when the user pressed Escape or clicked
802 * the backdrop (reason 'cancel').
803 */
804 OVERVIEW_EXITING: "desktop-mode.overview.exiting",
805 /** Action, fires once the overview-exit animation has settled. */
806 OVERVIEW_EXITED: "desktop-mode.overview.exited",
807 /** Action, fires when the cursor enters a thumbnail. Payload `{ windowId }`. */
808 OVERVIEW_WINDOW_HOVER: "desktop-mode.overview.window-hover",
809 /** Action, fires when the cursor leaves a thumbnail. Payload `{ windowId }`. */
810 OVERVIEW_WINDOW_UNHOVER: "desktop-mode.overview.window-unhover",
811 /** Action, fires the instant a thumbnail click is registered (before exit + maximize kick in). Payload `{ windowId }`. */
812 OVERVIEW_WINDOW_CLICK: "desktop-mode.overview.window-click",
813 /** Action, fires before cascade computes + applies new positions. Payload `{ windowCount }`. */
814 ARRANGE_CASCADE_STARTING: "desktop-mode.arrange.cascade.starting",
815 /** Action, fires after cascade has positioned every window. Payload `{ windowCount }`. */
816 ARRANGE_CASCADE_APPLIED: "desktop-mode.arrange.cascade.applied",
817 /** Action, fires before tile computes + applies new positions. Payload `{ windowCount, cols, rows }`. */
818 ARRANGE_TILE_STARTING: "desktop-mode.arrange.tile.starting",
819 /** Action, fires after tile has positioned every window. Payload `{ windowCount, cols, rows }`. */
820 ARRANGE_TILE_APPLIED: "desktop-mode.arrange.tile.applied",
821 /**
822 * Filter on the tile-grid dimensions chosen by the built-in
823 * algorithm. Receives `{ cols, rows }` plus a context arg
824 * `{ windowCount, areaWidth, areaHeight }`. Plugins can return
825 * a different `{ cols, rows }` to enforce a custom layout
826 * (fixed-column newsroom, golden-ratio cells, etc.). Returned
827 * values are validated — non-positive integers, or a product
828 * smaller than `windowCount`, fall back to the original.
829 */
830 ARRANGE_TILE_DIMENSIONS: "desktop-mode.arrange.tile.dimensions",
831 /** Action, fires when snap-to-grid is toggled. Payload `{ enabled }`. */
832 ARRANGE_SNAP_CHANGED: "desktop-mode.arrange.snap.changed",
833 /**
834 * Filter on the snap-grid cell size. Receives
835 * `{ cellWidth, cellHeight }` plus a context arg
836 * `{ areaWidth, areaHeight }`. Plugins can return different
837 * dimensions to enforce a Tetris-style fixed grid, a musical
838 * staff aspect, etc. Non-positive returns fall back to the
839 * original.
840 */
841 ARRANGE_SNAP_CELL_SIZE: "desktop-mode.arrange.snap.cell-size",
842 /**
843 * Action, fires when the user clicks a plugin-registered entry in
844 * the Arrange admin-bar submenu (items added via the
845 * `desktop_mode_arrange_menu_items` PHP filter). Payload `{ id }`
846 * where `id` is the item's `id` field as registered. Plugins
847 * subscribe here to run their custom arrangement logic.
848 */
849 ARRANGE_CUSTOM_ACTION: "desktop-mode.arrange.custom-action",
850 // ------------------------------------------------------------------
851 // Snap-zones — Windows-style edge snapping with a split-overview
852 // picker to fill the opposite half after commit.
853 // ------------------------------------------------------------------
854 /**
855 * Action, fires when the drag cursor enters a snap zone and the
856 * shell shows the target-position preview. Payload
857 * `{ windowId, zone: 'left' | 'right' }`.
858 */
859 SNAP_ZONE_PENDING: "desktop-mode.snap.zone-pending",
860 /**
861 * Action, fires when the drag cursor leaves the snap zone without
862 * releasing — the preview disappears. Payload `{ windowId }`.
863 */
864 SNAP_ZONE_CANCELED: "desktop-mode.snap.zone-canceled",
865 /**
866 * Action, fires once the window has animated into its snapped
867 * bounds. Payload `{ windowId, zone: 'left' | 'right' }`.
868 */
869 SNAP_ZONE_COMMITTED: "desktop-mode.snap.zone-committed",
870 /**
871 * Action, fires when a user picks a thumbnail from the split
872 * overview to fill the opposite half. Payload
873 * `{ windowId, zone: 'left' | 'right' }`.
874 */
875 SNAP_SPLIT_FILLED: "desktop-mode.snap.split-filled",
876 // ------------------------------------------------------------------
877 // Widgets — the right-side column. Widgets paint above the
878 // wallpaper but beneath windows. Lifecycle mirrors canvas
879 // wallpapers: register via filter, mount/unmount actions bracket
880 // each paint, mount-failed fires on sync throws / async rejects.
881 // ------------------------------------------------------------------
882 /** Filter, receives the widget registry array. */
883 WIDGETS: "desktop-mode.widgets",
884 /** Action before a widget mounts. Payload `{ id, container, ctx }`. */
885 WIDGET_MOUNTING: "desktop-mode.widget.mounting",
886 /** Action after a widget mounts successfully. Payload `{ id, container, ctx }`. */
887 WIDGET_MOUNTED: "desktop-mode.widget.mounted",
888 /** Action before a widget tears down. Payload `{ id }`. */
889 WIDGET_UNMOUNTING: "desktop-mode.widget.unmounting",
890 /** Action when a widget's mount throws / rejects. Payload `{ id, error }`. */
891 WIDGET_MOUNT_FAILED: "desktop-mode.widget.mount-failed",
892 /** Action when the user adds a widget via the picker. Payload `{ id }`. */
893 WIDGET_ADDED: "desktop-mode.widget.added",
894 /** Action when the user removes a widget via the card's × button. Payload `{ id }`. */
895 WIDGET_REMOVED: "desktop-mode.widget.removed",
896 // ------------------------------------------------------------------
897 // Virtual-desktop ("Spaces") lifecycle actions.
898 //
899 // Spaces let users group windows into separate workspaces and flip
900 // between them from the overview top bar. These hooks expose every
901 // state change so plugins can persist per-space state, sync custom
902 // indicators, or react to the user's workspace context.
903 // ------------------------------------------------------------------
904 /** Action, fires when a new desktop is created. Payload `{ desktopId }`. */
905 DESKTOP_CREATED: "desktop-mode.desktop.created",
906 /** Action, fires when a desktop is closed. Payload `{ desktopId, migratedTo }`. */
907 DESKTOP_CLOSED: "desktop-mode.desktop.closed",
908 /** Action, fires when the active desktop changes. Payload `{ from, to }`. */
909 DESKTOP_SWITCHED: "desktop-mode.desktop.switched",
910 /**
911 * Filter. Returns the id of the "primary" desktop — the one the
912 * shell treats as canonical for batch operations. Receives the
913 * default (first desktop's id) and the full `Desktop[]` list.
914 * @since 0.5.0
915 */
916 PRIMARY_DESKTOP_ID: "desktop-mode.primary-desktop-id",
917 // ------------------------------------------------------------------
918 // Batch window operations.
919 // ------------------------------------------------------------------
920 /**
921 * Action, fires before {@link WindowManager.closeAll} starts
922 * iterating. Payload `{ candidates: Window[] }` — every window the
923 * shell is about to close (after `exceptIds` was applied).
924 * @since 0.5.0
925 */
926 WINDOWS_BEFORE_CLOSE_ALL: "desktop-mode.windows.before-close-all",
927 /**
928 * Filter, runs inside {@link WindowManager.closeAll}. Receives the
929 * candidate `Window[]` list and returns the (possibly trimmed) list
930 * that will actually be closed. Plugins use this to PROTECT specific
931 * windows from a bulk close — e.g. keep the active draft open.
932 * Returning an empty array cancels the close entirely.
933 * @since 0.5.0
934 */
935 WINDOWS_CLOSE_ALL: "desktop-mode.windows.close-all",
936 /**
937 * Action, fires after {@link WindowManager.closeAll} has finished.
938 * Payload `{ closed: number, skipped: Window[] }`.
939 * @since 0.5.0
940 */
941 WINDOWS_AFTER_CLOSE_ALL: "desktop-mode.windows.after-close-all",
942 // ------------------------------------------------------------------
943 // Slash-command lifecycle.
944 // ------------------------------------------------------------------
945 /**
946 * Filter. Runs immediately before a command's `run()` is invoked.
947 * Receives `{ proceed: true, slug, args, command }` and may return
948 * the same shape with `proceed: false` to cancel the run.
949 * @since 0.5.0
950 */
951 COMMAND_BEFORE_RUN: "desktop-mode.command.before-run",
952 /**
953 * Action, fires after a command's `run()` resolves successfully.
954 * Payload `{ slug, args, command, result }`.
955 * @since 0.5.0
956 */
957 COMMAND_AFTER_RUN: "desktop-mode.command.after-run",
958 /**
959 * Action, fires when a command's `run()` throws. Payload
960 * `{ slug, args, command, error }`.
961 * @since 0.5.0
962 */
963 COMMAND_ERROR: "desktop-mode.command.error",
964 // ------------------------------------------------------------------
965 // Shell-level lifecycle actions.
966 // ------------------------------------------------------------------
967 /**
968 * Action, fires (debounced) after the browser viewport stops
969 * resizing. Payload `{ width, height }` describes the shell's
970 * bounding rect — plugins that render canvas-driven UIs hook here
971 * to adjust their render surface.
972 */
973 SHELL_RESIZED: "desktop-mode.shell.resized",
974 /**
975 * Action mirroring `document.visibilitychange` for the shell as a
976 * whole. Payload `{ state: 'visible' | 'hidden' }`. Different from
977 * the wallpaper-specific visibility action in that it fires
978 * regardless of which wallpaper (if any) is active.
979 */
980 SHELL_VISIBILITY: "desktop-mode.shell.visibility",
981 /**
982 * Action — fires when a `wp.desktop.connect()` connection
983 * completes its iframe handshake. Payload:
984 * `{ connectionId, targetWindowId, topics }`.
985 *
986 * @since 0.5.2
987 */
988 CONNECTION_OPENED: "desktop-mode.connection.opened",
989 /**
990 * Action — fires when a connection tears down. Payload:
991 * `{ connectionId, reason: 'disconnect' | 'window-closed' | 'navigated' }`.
992 *
993 * @since 0.5.2
994 */
995 CONNECTION_CLOSED: "desktop-mode.connection.closed",
996 /**
997 * Action — fires for every message routed through a connection.
998 * Payload: `{ connectionId, topic, direction: 'in' | 'out' }`.
999 * Used for debug consoles + traffic auditing; high-volume topics
1000 * fire this many times per second, so subscribers should be
1001 * cheap.
1002 *
1003 * @since 0.5.2
1004 */
1005 CONNECTION_MESSAGE: "desktop-mode.connection.message",
1006 /**
1007 * Filter — fires when an iframe calls
1008 * `wp.desktop.iframe.requestConnection()`. Default value is
1009 * `true` (accept). Return `false` to reject, or an object
1010 * `{ topics: string[] }` to accept while narrowing the topic
1011 * list. `$context` carries `{ windowId, requestId, topics }`.
1012 *
1013 * @since 0.5.2
1014 */
1015 IFRAME_CONNECTION_REQUEST: "desktop-mode.iframe.connection-request",
1016 // ------------------------------------------------------------------
1017 // Window content relations & link renderers (since 0.9.4). A window
1018 // may carry a content identity ("I am comment 45 of post 123");
1019 // windows resolving to the same root form a relation group, and a
1020 // pluggable renderer draws the ties on the desktop. Engine:
1021 // `src/window-links/engine.ts`; registry:
1022 // `src/window-links/renderer-registry.ts`. See
1023 // `docs/examples/window-links.md`.
1024 // ------------------------------------------------------------------
1025 /**
1026 * Action — fires when a window's content identity is set, replaced,
1027 * or cleared. Payload: `{ windowId: string, content:
1028 * WindowContentRef | null, previous: WindowContentRef | null,
1029 * source: 'config' | 'bridge' | 'api' }`. The matching
1030 * `desktop-mode-window-content-changed` CustomEvent dispatches on
1031 * `document` with the same payload.
1032 *
1033 * @since 0.9.4
1034 */
1035 WINDOW_CONTENT_CHANGED: "desktop-mode.window-links.content-changed",
1036 /**
1037 * Action — fires when relation-group MEMBERSHIP changes (a window
1038 * gained/lost an identity, or a member window opened/closed).
1039 * Payload: `{ groups: WindowLinkGroup[] }`. Deliberately NOT fired
1040 * on move/resize (renderers get live geometry through their frame
1041 * subscription) nor on focus-recency reordering. The matching
1042 * `desktop-mode-window-link-groups-changed` CustomEvent dispatches
1043 * on `document` with the same payload.
1044 *
1045 * @since 0.9.4
1046 */
1047 WINDOW_LINK_GROUPS_CHANGED: "desktop-mode.window-links.groups-changed",
1048 /**
1049 * Filter — applied to every content identity as it is set, before
1050 * storage. Signature: `( ref: WindowContentRef | null, ctx: {
1051 * windowId: string, source: 'config' | 'bridge' | 'api' } ) =>
1052 * WindowContentRef | null`. Return `null` to suppress the identity,
1053 * or a rewritten ref to remap it (e.g. point a custom object type
1054 * at your own root scheme).
1055 *
1056 * @since 0.9.4
1057 */
1058 WINDOW_LINKS_CONTENT: "desktop-mode.window-links.content",
1059 /**
1060 * Filter — applied to the computed relation-group list on every
1061 * read (`wp.desktop.relations.groups()`). Signature:
1062 * `( groups: WindowLinkGroup[] ) => WindowLinkGroup[]`. Merge,
1063 * split, or inject groups here.
1064 *
1065 * @since 0.9.4
1066 */
1067 WINDOW_LINK_GROUPS: "desktop-mode.window-links.groups",
1068 /**
1069 * Filter — applied to the derived directed-edge list on every read
1070 * (`wp.desktop.relations.edges()`). Signature: `( edges:
1071 * WindowLinkEdge[] ) => WindowLinkEdge[]` where each edge is
1072 * `{ fromWindowId, toWindowId, kind: 'child-root' | 'reference',
1073 * bidirectional }`. Add, drop, or redirect ties here — this is
1074 * what the render host feeds to the active renderer.
1075 *
1076 * @since 0.9.4
1077 */
1078 WINDOW_LINK_EDGES: "desktop-mode.window-links.edges",
1079 /**
1080 * Filter — applied to the registered window-link renderer list on
1081 * every read (`wp.desktop.listWindowLinkRenderers()`). Signature:
1082 * `( defs: WindowLinkRendererDef[] ) => WindowLinkRendererDef[]`.
1083 *
1084 * @since 0.9.4
1085 */
1086 WINDOW_LINK_RENDERERS: "desktop-mode.window-links.renderers",
1087 /**
1088 * Filter — applied to the resolved ACTIVE renderer id after the OS
1089 * Settings selection is read, before the registry lookup.
1090 * Signature: `( id: string ) => string`. Return a different
1091 * registered id (or `'none'`) to force-swap the renderer without
1092 * touching the user's setting.
1093 *
1094 * @since 0.9.4
1095 */
1096 WINDOW_LINK_RENDERER: "desktop-mode.window-links.renderer",
1097 // ------------------------------------------------------------------
1098 // OS-file drop manager (since 0.30.0). Catches files dragged from
1099 // the user's host OS (Finder / Explorer / Nautilus) onto any
1100 // desktop-mode surface and routes them through a confirmation
1101 // dialog before uploading to the Media Library. Authoritative
1102 // constants live in `src/os-file-drop/hooks.ts`; mirrored here so
1103 // every hook the shell fires is reachable from a single `HOOKS`
1104 // import. See `docs/examples/os-file-drop.md`.
1105 // ------------------------------------------------------------------
1106 /** Filter — `(files: File[], ctx) => File[]`, before mime/size check. */
1107 FILE_DROP_FILES_DETECTED: "desktop-mode.drop.files-detected",
1108 /** Action — `{ rejections, context }` for files that failed policy. */
1109 FILE_DROP_FILES_REJECTED: "desktop-mode.drop.files-rejected",
1110 /** Filter — `(entry, ctx) => entry`, per-file dialog defaults. */
1111 FILE_DROP_DIALOG_FIELDS: "desktop-mode.drop.dialog-fields",
1112 /** Filter — `(payload, ctx) => payload | null`, last call before POST. */
1113 FILE_DROP_BEFORE_UPLOAD: "desktop-mode.drop.before-upload",
1114 /** Action — `{ file, fields, context, abort }` once XHR is open and about to send. @since 0.31.0 */
1115 FILE_DROP_UPLOAD_STARTED: "desktop-mode.drop.upload-started",
1116 /** Action — `{ file, fields, context, loaded, total, indeterminate }` per progress tick. @since 0.31.0 */
1117 FILE_DROP_UPLOAD_PROGRESS: "desktop-mode.drop.upload-progress",
1118 /** Action — `{ file, result, fields, context }` after successful upload. `file` since 0.31.0. */
1119 FILE_DROP_AFTER_UPLOAD: "desktop-mode.drop.after-upload",
1120 /** Action — `{ file, error, context }` on upload failure. */
1121 FILE_DROP_UPLOAD_FAILED: "desktop-mode.drop.upload-failed"
1122 };
1123 const CANARY_TAG = "wpd-confirm-dialog";
1124 let inflight = null;
1125 function isLoaded() {
1126 return typeof window.customElements !== "undefined" && !!window.customElements.get(CANARY_TAG);
1127 }
1128 function injectScript(scriptUrl) {
1129 return new Promise((resolve, reject) => {
1130 const existing = document.querySelector(
1131 'script[data-desktop-mode-shell-overlays="1"]'
1132 );
1133 const finish = () => {
1134 if (isLoaded()) {
1135 resolve();
1136 return;
1137 }
1138 reject(
1139 new Error(
1140 "[desktop-mode] shell-overlays bundle loaded but did not register the overlay components."
1141 )
1142 );
1143 };
1144 if (existing) {
1145 if (isLoaded()) {
1146 finish();
1147 } else {
1148 existing.addEventListener("load", finish);
1149 existing.addEventListener(
1150 "error",
1151 () => reject(new Error("failed to load shell-overlays bundle"))
1152 );
1153 }
1154 return;
1155 }
1156 const s = document.createElement("script");
1157 s.src = scriptUrl;
1158 s.async = true;
1159 s.dataset.desktopModeShellOverlays = "1";
1160 s.addEventListener("load", finish);
1161 s.addEventListener(
1162 "error",
1163 () => reject(new Error("failed to load shell-overlays bundle"))
1164 );
1165 document.head.appendChild(s);
1166 });
1167 }
1168 function ensureShellOverlaysLoaded(scriptUrl) {
1169 if (isLoaded()) {
1170 return Promise.resolve();
1171 }
1172 if (!scriptUrl) {
1173 return Promise.resolve();
1174 }
1175 if (!inflight) {
1176 inflight = injectScript(scriptUrl);
1177 }
1178 return inflight;
1179 }
1180 function shellOverlaysBundleUrl() {
1181 const cfg = window.desktopModeConfig;
1182 return cfg?.shellOverlaysBundleUrl ?? "";
1183 }
1184 async function wpdConfirm(options) {
1185 await ensureShellOverlaysLoaded(shellOverlaysBundleUrl());
1186 return new Promise((resolve) => {
1187 const dialog = document.createElement("wpd-confirm-dialog");
1188 dialog.setAttribute("open", "");
1189 if (options.title) {
1190 dialog.setAttribute("title", options.title);
1191 }
1192 dialog.setAttribute("message", options.message);
1193 if (options.confirmLabel) {
1194 dialog.setAttribute("confirm-label", options.confirmLabel);
1195 }
1196 if (options.cancelLabel) {
1197 dialog.setAttribute("cancel-label", options.cancelLabel);
1198 }
1199 if (options.danger) {
1200 dialog.setAttribute("danger", "");
1201 }
1202 if (options.hideCancel) {
1203 dialog.setAttribute("hide-cancel", "");
1204 }
1205 if (options.dismissable) {
1206 dialog.setAttribute("dismissable", "");
1207 }
1208 const cleanup = (ok) => {
1209 dialog.remove();
1210 resolve(ok);
1211 };
1212 dialog.addEventListener("wpd-confirm", () => cleanup(true));
1213 dialog.addEventListener("wpd-cancel", () => cleanup(false));
1214 document.body.appendChild(dialog);
1215 const inner = dialog.shadowRoot?.querySelector(".dialog");
1216 (inner ?? dialog).focus?.();
1217 });
1218 }
1219 const NONCE_HEADER = "X-WP-Nonce";
1220 function injectRestNonce(input, init) {
1221 const nonce = readRestNonce();
1222 if (!nonce) {
1223 return init;
1224 }
1225 const url = resolveUrl(input);
1226 if (!url || !isSameOriginRestUrl(url)) {
1227 return init;
1228 }
1229 const baseHeaders = init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0);
1230 const headers = new Headers(baseHeaders ?? {});
1231 if (headers.has(NONCE_HEADER)) {
1232 return init;
1233 }
1234 headers.set(NONCE_HEADER, nonce);
1235 return { ...init ?? {}, headers };
1236 }
1237 function readRestNonce() {
1238 if (typeof window === "undefined") {
1239 return void 0;
1240 }
1241 const cfg = window.desktopModeConfig;
1242 const value = cfg?.restNonce;
1243 return typeof value === "string" && value.length > 0 ? value : void 0;
1244 }
1245 function resolveUrl(input) {
1246 try {
1247 const base = typeof window !== "undefined" && window.location ? window.location.href : void 0;
1248 if (typeof input === "string") {
1249 return new URL(input, base);
1250 }
1251 if (input instanceof URL) {
1252 return input;
1253 }
1254 if (typeof Request !== "undefined" && input instanceof Request) {
1255 return new URL(input.url, base);
1256 }
1257 return null;
1258 } catch {
1259 return null;
1260 }
1261 }
1262 function isSameOriginRestUrl(url) {
1263 if (typeof window === "undefined" || !window.location || url.origin !== window.location.origin) {
1264 return false;
1265 }
1266 if (url.pathname.includes("/wp-json/")) {
1267 return true;
1268 }
1269 if (url.searchParams.has("rest_route")) {
1270 return true;
1271 }
1272 return false;
1273 }
1274 function trackedFetch(input, init, opts = {}) {
1275 const fn = window.wp?.desktop?.fetch;
1276 if (typeof fn === "function") {
1277 return fn(input, init, opts);
1278 }
1279 const finalInit = injectRestNonce(input, init);
1280 return fetch(input, finalInit);
1281 }
1282 const SHARED_STORES_SLOT = "__desktopModeSharedStores";
1283 function resolveSlot() {
1284 const w = window;
1285 let slot = w[SHARED_STORES_SLOT];
1286 if (!slot) {
1287 slot = /* @__PURE__ */ new Map();
1288 w[SHARED_STORES_SLOT] = slot;
1289 }
1290 return slot;
1291 }
1292 function createSharedStore(key, initialState) {
1293 const slot = resolveSlot();
1294 let record = slot.get(key);
1295 if (!record) {
1296 record = {
1297 state: initialState(),
1298 listeners: /* @__PURE__ */ new Set(),
1299 rebuild: initialState
1300 };
1301 slot.set(key, record);
1302 }
1303 const handle = {
1304 // `record.state` is the live reference. The getter on the
1305 // `state` field reads the latest value even if `reset()`
1306 // reassigned it to a fresh object.
1307 get state() {
1308 return record.state;
1309 },
1310 set state(next) {
1311 record.state = next;
1312 },
1313 getState() {
1314 return record.state;
1315 },
1316 notify() {
1317 for (const cb of Array.from(record.listeners)) {
1318 try {
1319 cb(record.state);
1320 } catch (err) {
1321 console.error(
1322 `[desktop-mode/shared-store:${key}] subscriber threw:`,
1323 err
1324 );
1325 }
1326 }
1327 },
1328 subscribe(cb) {
1329 record.listeners.add(cb);
1330 return () => {
1331 record.listeners.delete(cb);
1332 };
1333 },
1334 setState(patch) {
1335 const cur = record.state;
1336 if (typeof cur !== "object" || cur === null) {
1337 console.warn(
1338 `[desktop-mode/shared-store:${key}] setState called on a primitive store; use the state setter instead.`
1339 );
1340 return;
1341 }
1342 Object.assign(cur, patch);
1343 handle.notify();
1344 },
1345 reset() {
1346 const fresh = record.rebuild();
1347 const cur = record.state;
1348 if (typeof cur === "object" && cur !== null && typeof fresh === "object" && fresh !== null) {
1349 const target = cur;
1350 for (const k of Object.keys(target)) {
1351 delete target[k];
1352 }
1353 Object.assign(target, fresh);
1354 } else {
1355 record.state = fresh;
1356 }
1357 record.listeners.clear();
1358 }
1359 };
1360 return handle;
1361 }
1362 const commandRegistryStore = createSharedStore(
1363 "desktop-mode/commands-registry",
1364 () => ({
1365 registry: /* @__PURE__ */ new Map(),
1366 listeners: /* @__PURE__ */ new Set()
1367 })
1368 );
1369 const registry = commandRegistryStore.state.registry;
1370 const listeners = commandRegistryStore.state.listeners;
1371 function listCommands() {
1372 return Array.from(registry.values());
1373 }
1374 function listEagerCommands() {
1375 return Array.from(registry.values()).filter((c) => c.eager === true);
1376 }
1377 function findCommand(slug) {
1378 return registry.get(slug.toLowerCase()) ?? null;
1379 }
1380 function filterCommands(query) {
1381 const q = query.trim().toLowerCase();
1382 if (q === "") {
1383 return listCommands();
1384 }
1385 return listCommands().filter(
1386 (c) => c.slug.toLowerCase().startsWith(q) || c.label.toLowerCase().includes(q)
1387 );
1388 }
1389 function subscribeCommands(cb) {
1390 listeners.add(cb);
1391 return () => {
1392 listeners.delete(cb);
1393 };
1394 }
1395 function parseCommandInput(input) {
1396 if (!input.startsWith("/")) {
1397 return { isCommand: false, slug: "", args: "", hasArgsPart: false };
1398 }
1399 const rest = input.slice(1);
1400 const spaceIdx = rest.indexOf(" ");
1401 if (spaceIdx === -1) {
1402 return { isCommand: true, slug: rest, args: "", hasArgsPart: false };
1403 }
1404 return {
1405 isCommand: true,
1406 slug: rest.slice(0, spaceIdx),
1407 args: rest.slice(spaceIdx + 1),
1408 hasArgsPart: true
1409 };
1410 }
1411 function escapeHtmlForMd(s) {
1412 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1413 }
1414 function renderInlineMd(s) {
1415 return s.replace(
1416 /\[([^\]]+)\]\(([^)]+)\)/g,
1417 (_m, label, url) => {
1418 if (!/^https?:\/\//i.test(url.trim())) {
1419 return label;
1420 }
1421 return `<a href="${url.trim()}" target="_blank" rel="noopener noreferrer">${label}</a>`;
1422 }
1423 ).replace(/\*\*([^*\n]+?)\*\*/g, "<strong>$1</strong>").replace(/(?<![*\w])\*([^*\n]+?)\*(?![*\w])/g, "<em>$1</em>").replace(/(?<![_\w])_([^_\n]+?)_(?![_\w])/g, "<em>$1</em>").replace(/`([^`\n]+?)`/g, "<code>$1</code>");
1424 }
1425 function renderMarkdown(md) {
1426 if (!md) {
1427 return "";
1428 }
1429 const safe = escapeHtmlForMd(md);
1430 const blocks = safe.split(/\n\s*\n/);
1431 const out = [];
1432 for (const raw of blocks) {
1433 const lines = raw.split(/\n/).map((l) => l.trim()).filter((l) => l !== "");
1434 if (lines.length === 0) {
1435 continue;
1436 }
1437 const isUL = lines.every((l) => /^[-*]\s+/.test(l));
1438 const isOL = lines.every((l) => /^\d+\.\s+/.test(l));
1439 if (isUL) {
1440 const items = lines.map(
1441 (l) => `<li>${renderInlineMd(l.replace(/^[-*]\s+/, ""))}</li>`
1442 );
1443 out.push(`<ul>${items.join("")}</ul>`);
1444 } else if (isOL) {
1445 const items = lines.map(
1446 (l) => `<li>${renderInlineMd(l.replace(/^\d+\.\s+/, ""))}</li>`
1447 );
1448 out.push(`<ol>${items.join("")}</ol>`);
1449 } else {
1450 out.push(`<p>${renderInlineMd(lines.join("<br>"))}</p>`);
1451 }
1452 }
1453 return out.join("");
1454 }
1455 const ICON_SPARKLE = `<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true" focusable="false" fill="currentColor">
1456 <path d="M10 2 L11.8 7.8 L17.5 9.5 L11.8 11.2 L10 17 L8.2 11.2 L2.5 9.5 L8.2 7.8 Z"/>
1457 </svg>`;
1458 const ICON_CLOSE = `<svg viewBox="0 0 14 14" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
1459 <line x1="2" y1="2" x2="12" y2="12"/>
1460 <line x1="12" y1="2" x2="2" y2="12"/>
1461 </svg>`;
1462 const ICON_RETURN = `<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1463 <polyline points="14,4 14,10 3,10"/>
1464 <polyline points="6,7 3,10 6,13"/>
1465 </svg>`;
1466 const ICON_SPINNER = `<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="desktop-mode-ai__spinner-icon">
1467 <circle cx="10" cy="10" r="7" stroke-opacity="0.25"/>
1468 <path d="M10 3 A7 7 0 0 1 17 10" stroke-opacity="1"/>
1469 </svg>`;
1470 const ICON_ARROW = `<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1471 <polyline points="6,3 11,8 6,13"/>
1472 </svg>`;
1473 const SUGGESTED_PROMPTS = [
1474 "Find my post about…",
1475 "Where can I see categories?",
1476 "Do I have any spam comments?",
1477 "Take me to plugin settings"
1478 ];
1479 class AiAssistant {
1480 constructor(config) {
1481 this._isOpen = false;
1482 this._isSearching = false;
1483 this._previousFocus = null;
1484 this._currentStream = null;
1485 this._selectedCommand = 0;
1486 this._keyboardNav = false;
1487 this._selectedSuggestion = 0;
1488 this._currentSuggestions = [];
1489 this._suggestToken = 0;
1490 this.ask = () => {
1491 throw new Error(
1492 "[desktop-mode] wp.desktop.ai.ask called before the shell finished booting."
1493 );
1494 };
1495 this._aiSearchUrl = config.aiSearchUrl;
1496 this._aiSearchStreamUrl = config.aiSearchStreamUrl;
1497 this._restNonce = config.restNonce;
1498 this._getTransport = config.getTransport ?? (() => "off");
1499 this._el = this._buildDOM();
1500 document.body.appendChild(this._el);
1501 this._input = this._el.querySelector(".desktop-mode-ai__input");
1502 this._submitBtn = this._el.querySelector(".desktop-mode-ai__submit");
1503 this._closeBtn = this._el.querySelector(".desktop-mode-ai__close");
1504 this._resultsEl = this._el.querySelector(".desktop-mode-ai__results");
1505 this._bindEvents();
1506 this._renderSuggestions();
1507 subscribeCommands(() => {
1508 if (!this._isOpen) {
1509 return;
1510 }
1511 if (this._input.value.startsWith("/")) {
1512 this._renderCommandMode();
1513 } else if (this._input.value === "" && listEagerCommands().length > 0) {
1514 this._renderCommandMode();
1515 }
1516 });
1517 }
1518 // ------------------------------------------------------------------
1519 // Public API
1520 // ------------------------------------------------------------------
1521 open() {
1522 if (this._isOpen) {
1523 this._input.focus();
1524 this._input.select();
1525 return;
1526 }
1527 this._isOpen = true;
1528 this._previousFocus = this._el.ownerDocument.activeElement;
1529 this._input.value = "";
1530 this._selectedCommand = 0;
1531 this._submitBtn.classList.remove("has-value");
1532 if (listEagerCommands().length > 0) {
1533 this._renderCommandMode();
1534 } else {
1535 this._renderSuggestions();
1536 }
1537 this._el.removeAttribute("hidden");
1538 void this._el.offsetHeight;
1539 this._el.classList.add("is-open");
1540 this._el.setAttribute("aria-hidden", "false");
1541 requestAnimationFrame(() => this._input.focus());
1542 }
1543 close() {
1544 if (!this._isOpen) {
1545 return;
1546 }
1547 this._isOpen = false;
1548 this._el.classList.remove("is-open");
1549 this._el.setAttribute("aria-hidden", "true");
1550 this._closeStream();
1551 this._isSearching = false;
1552 this._submitBtn.disabled = false;
1553 this._input.disabled = false;
1554 const onEnd = (e) => {
1555 if (e.target !== this._el || e.propertyName !== "opacity") {
1556 return;
1557 }
1558 this._el.setAttribute("hidden", "");
1559 this._el.removeEventListener("transitionend", onEnd);
1560 if (this._previousFocus instanceof HTMLElement) {
1561 this._previousFocus.focus();
1562 }
1563 };
1564 this._el.addEventListener("transitionend", onEnd);
1565 }
1566 toggle() {
1567 if (this._isOpen) {
1568 this.close();
1569 } else {
1570 this.open();
1571 }
1572 }
1573 get isOpen() {
1574 return this._isOpen;
1575 }
1576 /** Late-binding helper used by `desktop.ts`. Not part of the public API. */
1577 attachAsk(fn) {
1578 this.ask = fn;
1579 }
1580 // ------------------------------------------------------------------
1581 // Events
1582 // ------------------------------------------------------------------
1583 _bindEvents() {
1584 this._el.addEventListener("keydown", (e) => {
1585 if (e.key === "Escape") {
1586 e.stopPropagation();
1587 this.close();
1588 }
1589 });
1590 this._el.addEventListener("keydown", (e) => {
1591 if (e.key !== "Tab") {
1592 return;
1593 }
1594 const focusable = [this._closeBtn, this._input, this._submitBtn].filter((el) => !el.disabled);
1595 const first = focusable[0];
1596 const last = focusable[focusable.length - 1];
1597 const active = this._el.ownerDocument.activeElement;
1598 if (e.shiftKey && active === first) {
1599 e.preventDefault();
1600 last.focus();
1601 } else if (!e.shiftKey && active === last) {
1602 e.preventDefault();
1603 first.focus();
1604 }
1605 });
1606 document.addEventListener("desktop-mode-open-ai", () => this.open());
1607 this._closeBtn.addEventListener("click", () => this.close());
1608 this._submitBtn.addEventListener("click", () => this._onSubmit());
1609 this._input.addEventListener("keydown", (e) => {
1610 const parsed = parseCommandInput(this._input.value);
1611 const eagerPicking = parsed.isCommand === false && this._input.value === "" && listEagerCommands().length > 0;
1612 if (parsed.isCommand && !parsed.hasArgsPart || eagerPicking) {
1613 const matches = this._sortCommands(
1614 eagerPicking ? listEagerCommands() : filterCommands(parsed.slug).filter((c) => c.eager !== true)
1615 );
1616 if (e.key === "ArrowDown") {
1617 e.preventDefault();
1618 this._selectedCommand = Math.min(
1619 this._selectedCommand + 1,
1620 Math.max(0, matches.length - 1)
1621 );
1622 this._keyboardNav = true;
1623 this._paintCommandSelection();
1624 return;
1625 }
1626 if (e.key === "ArrowUp") {
1627 e.preventDefault();
1628 this._selectedCommand = Math.max(0, this._selectedCommand - 1);
1629 this._keyboardNav = true;
1630 this._paintCommandSelection();
1631 return;
1632 }
1633 if (e.key === "Tab" && matches.length > 0 && !eagerPicking) {
1634 e.preventDefault();
1635 const pick = matches[this._selectedCommand] ?? matches[0];
1636 this._input.value = `/${pick.slug} `;
1637 this._submitBtn.classList.add("has-value");
1638 this._selectedSuggestion = 0;
1639 this._renderCommandMode();
1640 return;
1641 }
1642 if (e.key === "Enter" && !e.shiftKey) {
1643 e.preventDefault();
1644 if (matches.length === 0) {
1645 this._showError(`Unknown command: /${parsed.slug}`);
1646 return;
1647 }
1648 const pick = matches[this._selectedCommand] ?? matches[0];
1649 this._runCommand(pick, "");
1650 return;
1651 }
1652 }
1653 if (parsed.isCommand && parsed.hasArgsPart) {
1654 const cmd = findCommand(parsed.slug);
1655 const hasSuggest = !!cmd && typeof cmd.suggest === "function";
1656 if (hasSuggest && this._currentSuggestions.length > 0) {
1657 if (e.key === "ArrowDown") {
1658 e.preventDefault();
1659 this._selectedSuggestion = Math.min(
1660 this._selectedSuggestion + 1,
1661 this._currentSuggestions.length - 1
1662 );
1663 this._paintSuggestionSelection();
1664 return;
1665 }
1666 if (e.key === "ArrowUp") {
1667 e.preventDefault();
1668 this._selectedSuggestion = Math.max(0, this._selectedSuggestion - 1);
1669 this._paintSuggestionSelection();
1670 return;
1671 }
1672 if (e.key === "Tab") {
1673 e.preventDefault();
1674 const pick = this._currentSuggestions[this._selectedSuggestion];
1675 if (pick) {
1676 this._input.value = `/${parsed.slug} ${pick.value}`;
1677 }
1678 return;
1679 }
1680 if (e.key === "Enter" && !e.shiftKey && cmd) {
1681 e.preventDefault();
1682 const pick = this._currentSuggestions[this._selectedSuggestion];
1683 const finalArgs = pick ? pick.value : parsed.args;
1684 this._runCommand(cmd, finalArgs);
1685 return;
1686 }
1687 }
1688 }
1689 if (e.key === "Enter" && !e.shiftKey) {
1690 e.preventDefault();
1691 this._onSubmit();
1692 }
1693 });
1694 this._input.addEventListener("input", () => {
1695 const hasValue = this._input.value.trim().length > 0;
1696 this._submitBtn.classList.toggle("has-value", hasValue);
1697 this._selectedCommand = 0;
1698 this._selectedSuggestion = 0;
1699 if (this._input.value.startsWith("/")) {
1700 this._renderCommandMode();
1701 } else if (!hasValue) {
1702 if (listEagerCommands().length > 0) {
1703 this._renderCommandMode();
1704 } else {
1705 this._renderSuggestions();
1706 }
1707 } else ;
1708 });
1709 this._resultsEl.addEventListener("mousemove", () => {
1710 if (this._keyboardNav) {
1711 this._keyboardNav = false;
1712 const list = this._resultsEl.querySelector(".desktop-mode-ai__cmd-list");
1713 if (list) {
1714 list.classList.remove("desktop-mode-ai__cmd-list--kb-nav");
1715 }
1716 }
1717 });
1718 }
1719 // ------------------------------------------------------------------
1720 // Flow
1721 // ------------------------------------------------------------------
1722 async _onSubmit() {
1723 const raw = this._input.value.trim();
1724 if (!raw || this._isSearching) {
1725 return;
1726 }
1727 const parsed = parseCommandInput(this._input.value);
1728 if (parsed.isCommand) {
1729 const cmd = findCommand(parsed.slug);
1730 if (!cmd) {
1731 this._showError(`Unknown command: /${parsed.slug}`);
1732 return;
1733 }
1734 await this._runCommand(cmd, parsed.args);
1735 return;
1736 }
1737 await this._runSearch(raw, null, 0);
1738 }
1739 /**
1740 * Invoke a plugin-registered command. Handles both sync and async
1741 * handlers, renders the return value the same way we render an AI
1742 * answer, and surfaces thrown errors as an error-state bubble.
1743 */
1744 async _runCommand(cmd, args) {
1745 if (this._isSearching) {
1746 return;
1747 }
1748 const gate = applyFilters(HOOKS.COMMAND_BEFORE_RUN, {
1749 proceed: true,
1750 slug: cmd.slug,
1751 args,
1752 command: cmd
1753 });
1754 if (gate && gate.proceed === false) {
1755 this._showError(
1756 gate.reason ?? `Command /${cmd.slug} was cancelled.`
1757 );
1758 return;
1759 }
1760 this._isSearching = true;
1761 this._submitBtn.disabled = true;
1762 this._input.disabled = true;
1763 this._showThinking(`Running /${cmd.slug}`);
1764 const ctx = {
1765 // Command-initiated close: skip the previousFocus restore.
1766 // The command is responsible for any focus management
1767 // (e.g. iframe-bridge.runProxy calls `manager.focus(target)`
1768 // immediately after `ctx.close()`). The default restore
1769 // fires on the close-transition's `transitionend` ~300ms
1770 // later, which would otherwise yank focus back to whatever
1771 // element was active before the palette opened — typically
1772 // an element inside a sibling window's iframe — dragging
1773 // that sibling window to the front and undoing the
1774 // command's focus choice. User-initiated closes (Escape,
1775 // click outside) still restore previousFocus as before.
1776 close: () => {
1777 this._previousFocus = null;
1778 this.close();
1779 },
1780 openInWindow: (url, title, icon) => this._openInLegacyWindow(url, title, icon),
1781 confirm: (msg, details) => this._confirm(msg, details)
1782 };
1783 try {
1784 const result = await Promise.resolve(cmd.run(args, ctx));
1785 this._renderCommandResult(cmd, result);
1786 doAction(HOOKS.COMMAND_AFTER_RUN, {
1787 slug: cmd.slug,
1788 args,
1789 command: cmd,
1790 result
1791 });
1792 } catch (err) {
1793 const msg = err instanceof Error ? err.message : String(err);
1794 this._showError(`Command /${cmd.slug} failed: ${msg}`);
1795 doAction(HOOKS.COMMAND_ERROR, {
1796 slug: cmd.slug,
1797 args,
1798 command: cmd,
1799 error: err
1800 });
1801 } finally {
1802 this._isSearching = false;
1803 this._submitBtn.disabled = false;
1804 this._input.disabled = false;
1805 this._input.focus();
1806 }
1807 }
1808 /**
1809 * Default `ctx.confirm()` — uses the framework `<wpd-confirm-dialog>`
1810 * so the prompt matches the rest of the desktop visually. Plugins
1811 * can swap in their own implementation; the Promise<boolean>
1812 * contract is stable.
1813 */
1814 _confirm(message, details) {
1815 return wpdConfirm({
1816 title: details ? message : void 0,
1817 message: details ?? message
1818 });
1819 }
1820 /**
1821 * Render the value returned by a command. A `void` return means
1822 * the command performed a side-effect (e.g. opened a window) and
1823 * doesn't need a bubble; in that case we clear the results area.
1824 * A plain string is shorthand for `{ message: string }`.
1825 */
1826 _renderCommandResult(_cmd, result) {
1827 if (result === void 0 || result === null) {
1828 this._resultsEl.innerHTML = "";
1829 this._resultsEl.hidden = true;
1830 return;
1831 }
1832 const answer = typeof result === "string" ? {
1833 answer_type: "chat",
1834 message: result,
1835 entity: null,
1836 admin_links: null,
1837 iterations: 0,
1838 exhausted: true,
1839 continue: null
1840 } : {
1841 answer_type: result.answer_type ?? "chat",
1842 message: result.message,
1843 entity: result.entity ?? null,
1844 admin_links: result.admin_links ?? null,
1845 iterations: 0,
1846 exhausted: true,
1847 continue: null
1848 };
1849 this._showResult("", answer);
1850 }
1851 _runSearch(query, resumeTool, startOffset) {
1852 if (this._isSearching) {
1853 return;
1854 }
1855 this._isSearching = true;
1856 this._submitBtn.disabled = true;
1857 this._input.disabled = true;
1858 this._showThinking("Thinking…");
1859 const useSse = this._getTransport() === "sse" && typeof EventSource !== "undefined" && !!this._aiSearchStreamUrl;
1860 if (useSse) {
1861 this._runSearchStream(query, resumeTool, startOffset);
1862 } else {
1863 this._runSearchFetch(query, resumeTool, startOffset);
1864 }
1865 }
1866 /**
1867 * EventSource-based streaming — the preferred path. Shows real-time
1868 * progress messages as the agent picks tools and runs them.
1869 */
1870 _runSearchStream(query, resumeTool, startOffset) {
1871 const url = new URL(this._aiSearchStreamUrl, window.location.origin);
1872 url.searchParams.set("nonce", this._restNonce);
1873 url.searchParams.set("query", query);
1874 if (resumeTool) {
1875 url.searchParams.set("resume_tool", resumeTool);
1876 url.searchParams.set("start_offset", String(startOffset));
1877 }
1878 this._closeStream();
1879 const es = new EventSource(url.toString());
1880 this._currentStream = es;
1881 const finish = () => {
1882 es.close();
1883 this._currentStream = null;
1884 this._isSearching = false;
1885 this._submitBtn.disabled = false;
1886 this._input.disabled = false;
1887 this._input.focus();
1888 };
1889 es.onmessage = (ev) => {
1890 let data;
1891 try {
1892 data = JSON.parse(ev.data);
1893 } catch {
1894 return;
1895 }
1896 if (!data || typeof data !== "object") {
1897 return;
1898 }
1899 switch (data.event) {
1900 case "open":
1901 break;
1902 case "progress":
1903 if (typeof data.message === "string") {
1904 this._showThinking(data.message);
1905 }
1906 break;
1907 case "done":
1908 if (data.result) {
1909 this._showResult(query, data.result);
1910 }
1911 finish();
1912 break;
1913 case "error":
1914 this._showError(data.message ?? "Something went wrong.", data.code);
1915 finish();
1916 break;
1917 }
1918 };
1919 es.onerror = () => {
1920 if (this._currentStream === es) {
1921 this._showError("Lost connection to the assistant. Please try again.");
1922 finish();
1923 }
1924 };
1925 }
1926 /**
1927 * Legacy fetch path — used when EventSource is not available.
1928 */
1929 async _runSearchFetch(query, resumeTool, startOffset) {
1930 try {
1931 const body = { query };
1932 if (resumeTool) {
1933 body.resume_tool = resumeTool;
1934 body.start_offset = startOffset;
1935 }
1936 const res = await trackedFetch(
1937 this._aiSearchUrl,
1938 {
1939 method: "POST",
1940 headers: {
1941 "Content-Type": "application/json",
1942 "X-WP-Nonce": this._restNonce
1943 },
1944 body: JSON.stringify(body)
1945 },
1946 { source: "desktop-mode/ai-search" }
1947 );
1948 if (!res.ok) {
1949 const err = await res.json().catch(() => ({}));
1950 this._showError(err.message ?? `Server returned ${res.status}`, err.code);
1951 return;
1952 }
1953 this._showResult(query, await res.json());
1954 } catch {
1955 this._showError("Network error — please check your connection and try again.");
1956 } finally {
1957 this._isSearching = false;
1958 this._submitBtn.disabled = false;
1959 this._input.disabled = false;
1960 this._input.focus();
1961 }
1962 }
1963 _closeStream() {
1964 if (this._currentStream) {
1965 this._currentStream.close();
1966 this._currentStream = null;
1967 }
1968 }
1969 // ------------------------------------------------------------------
1970 // Open helpers — everything opens as a legacy iframe window, not a
1971 // new browser tab, so the admin experience stays inside the desktop.
1972 // ------------------------------------------------------------------
1973 _getDesktopShell() {
1974 const shell = window.wp?.desktop;
1975 return shell ?? null;
1976 }
1977 /**
1978 * Open OS Settings on the Features tab so the user can turn the
1979 * assistant on in one click from the "assistant is off" error state.
1980 * Closes the assistant first so the settings window isn't hidden behind
1981 * it, and drops the stored focus target so closing doesn't bounce
1982 * focus back to the launcher away from the settings window.
1983 */
1984 _openAssistantSettings() {
1985 const shell = this._getDesktopShell();
1986 this._previousFocus = null;
1987 this.close();
1988 shell?.openOsSettings?.({ tabId: "features" });
1989 }
1990 _openInLegacyWindow(url, title, icon) {
1991 const shell = this._getDesktopShell();
1992 if (!shell || !shell.windowManager) {
1993 window.open(url, "_blank", "noopener");
1994 return;
1995 }
1996 const id = shell.deriveWindowId ? shell.deriveWindowId(url) : "desktop-mode-ai-" + url.replace(/[^a-z0-9]+/gi, "-").slice(0, 80);
1997 shell.windowManager.open({
1998 id,
1999 url,
2000 title,
2001 icon: icon ?? "dashicons-admin-generic"
2002 });
2003 this.close();
2004 }
2005 // ------------------------------------------------------------------
2006 // Rendering
2007 // ------------------------------------------------------------------
2008 /**
2009 * Render the slash-command palette — filtered list of commands
2010 * matching the current input. If the user has typed a slug followed
2011 * by a space, we're in "args" mode so we only show the one locked-in
2012 * command with a hint rather than a filterable list.
2013 */
2014 _renderCommandMode() {
2015 this._resultsEl.hidden = false;
2016 const parsed = parseCommandInput(this._input.value);
2017 if (parsed.hasArgsPart) {
2018 const cmd = findCommand(parsed.slug);
2019 if (cmd) {
2020 this._renderArgsMode(cmd, parsed.args);
2021 return;
2022 }
2023 }
2024 const eagerPicking = parsed.isCommand === false && this._input.value === "";
2025 const filtered = eagerPicking ? listEagerCommands() : filterCommands(parsed.slug).filter((c) => c.eager !== true);
2026 const matches = this._sortCommands(filtered);
2027 if (matches.length === 0) {
2028 this._resultsEl.innerHTML = `
2029 <div class="desktop-mode-ai__state desktop-mode-ai__state--empty">
2030 <span>No commands matching <strong>/${this._esc(parsed.slug)}</strong>.</span>
2031 </div>
2032 `;
2033 return;
2034 }
2035 if (this._selectedCommand >= matches.length) {
2036 this._selectedCommand = 0;
2037 }
2038 const items = matches.map((c, i) => {
2039 const selected = i === this._selectedCommand ? " is-selected" : "";
2040 return `
2041 <button
2042 type="button"
2043 class="desktop-mode-ai__cmd-item${selected}"
2044 data-slug="${this._esc(c.slug)}"
2045 data-index="${i}"
2046 >
2047 ${c.iconSvg ? `<span class="desktop-mode-ai__cmd-icon desktop-mode-ai__cmd-icon--svg" aria-hidden="true">${c.iconSvg}</span>` : `<span class="desktop-mode-ai__cmd-icon dashicons ${this._esc(c.icon ?? "dashicons-arrow-right-alt")}" aria-hidden="true"></span>`}
2048 <span class="desktop-mode-ai__cmd-body">
2049 <span class="desktop-mode-ai__cmd-title">
2050 ${this._esc(c.label)}
2051 ${c.hint ? `<span class="desktop-mode-ai__cmd-hint">${this._esc(c.hint)}</span>` : ""}
2052 </span>
2053 ${c.description ? `<span class="desktop-mode-ai__cmd-desc">${this._esc(c.description)}</span>` : ""}
2054 </span>
2055 </button>
2056 `;
2057 }).join("");
2058 this._resultsEl.innerHTML = `
2059 <div class="desktop-mode-ai__cmd-list">
2060 <p class="desktop-mode-ai__suggestions-label">Commands</p>
2061 ${items}
2062 </div>
2063 `;
2064 this._resultsEl.querySelectorAll(".desktop-mode-ai__cmd-item").forEach((btn) => {
2065 btn.addEventListener("click", () => {
2066 const slug = btn.dataset.slug ?? "";
2067 this._input.value = `/${slug} `;
2068 this._submitBtn.classList.add("has-value");
2069 this._input.focus();
2070 this._renderCommandMode();
2071 });
2072 btn.addEventListener("mouseenter", () => {
2073 if (this._keyboardNav) {
2074 return;
2075 }
2076 const idx = parseInt(btn.dataset.index ?? "0", 10);
2077 if (!Number.isNaN(idx)) {
2078 this._selectedCommand = idx;
2079 this._resultsEl.querySelectorAll(".desktop-mode-ai__cmd-item").forEach((el, i) => el.classList.toggle("is-selected", i === idx));
2080 }
2081 });
2082 });
2083 }
2084 /**
2085 * Render args-mode UI for a locked-in command. If the command has a
2086 * `suggest()` handler, fetch it (sync or async) and render the
2087 * returned list. Otherwise fall back to a single-row "Press Enter
2088 * to run" card.
2089 */
2090 _renderArgsMode(cmd, args) {
2091 if (typeof cmd.suggest !== "function") {
2092 this._currentSuggestions = [];
2093 this._resultsEl.innerHTML = this._renderCommandHeader(cmd, true);
2094 return;
2095 }
2096 const myToken = ++this._suggestToken;
2097 const ctx = {
2098 close: () => this.close(),
2099 openInWindow: (url, title, icon) => this._openInLegacyWindow(url, title, icon),
2100 confirm: (msg, details) => this._confirm(msg, details)
2101 };
2102 let result;
2103 try {
2104 result = cmd.suggest(args, ctx);
2105 } catch {
2106 result = [];
2107 }
2108 const render = (suggestions) => {
2109 if (myToken !== this._suggestToken) {
2110 return;
2111 }
2112 this._currentSuggestions = suggestions;
2113 if (this._selectedSuggestion >= suggestions.length) {
2114 this._selectedSuggestion = 0;
2115 }
2116 this._resultsEl.innerHTML = this._renderCommandHeader(cmd, false) + this._renderSuggestionList(suggestions);
2117 this._resultsEl.querySelectorAll(".desktop-mode-ai__cmd-suggest-item").forEach((btn) => {
2118 btn.addEventListener("click", () => {
2119 const idx = parseInt(btn.dataset.index ?? "0", 10);
2120 const pick = suggestions[idx];
2121 if (pick) {
2122 this._input.value = `/${cmd.slug} ${pick.value}`;
2123 this._runCommand(cmd, pick.value);
2124 }
2125 });
2126 btn.addEventListener("mouseenter", () => {
2127 const idx = parseInt(btn.dataset.index ?? "0", 10);
2128 if (!Number.isNaN(idx)) {
2129 this._selectedSuggestion = idx;
2130 this._paintSuggestionSelection();
2131 }
2132 });
2133 });
2134 };
2135 if (result && typeof result.then === "function") {
2136 this._resultsEl.innerHTML = this._renderCommandHeader(cmd, false);
2137 result.then((r) => render(Array.isArray(r) ? r : [])).catch(() => render([]));
2138 } else {
2139 render(Array.isArray(result) ? result : []);
2140 }
2141 }
2142 /** Render the command banner used at the top of args-mode. */
2143 _renderCommandHeader(cmd, standalone) {
2144 return `
2145 <div class="desktop-mode-ai__cmd-active">
2146 <span class="desktop-mode-ai__cmd-icon dashicons ${this._esc(
2147 cmd.icon ?? "dashicons-arrow-right-alt"
2148 )}" aria-hidden="true"></span>
2149 <div class="desktop-mode-ai__cmd-body">
2150 <span class="desktop-mode-ai__cmd-title">
2151 /${this._esc(cmd.slug)}
2152 ${cmd.hint ? `<span class="desktop-mode-ai__cmd-hint">${this._esc(cmd.hint)}</span>` : ""}
2153 </span>
2154 ${cmd.description ? `<span class="desktop-mode-ai__cmd-desc">${this._esc(cmd.description)}</span>` : ""}
2155 ${standalone ? '<span class="desktop-mode-ai__cmd-enter-hint">Press <kbd>↵</kbd> to run</span>' : ""}
2156 </div>
2157 </div>
2158 `;
2159 }
2160 /** Render the list of suggestions under the command header. */
2161 _renderSuggestionList(suggestions) {
2162 if (suggestions.length === 0) {
2163 return `
2164 <div class="desktop-mode-ai__state desktop-mode-ai__state--empty">
2165 <span>No suggestions — press <kbd>↵</kbd> to run with the text you typed.</span>
2166 </div>
2167 `;
2168 }
2169 const items = suggestions.map((s, i) => {
2170 const selected = i === this._selectedSuggestion ? " is-selected" : "";
2171 return `
2172 <button
2173 type="button"
2174 class="desktop-mode-ai__cmd-suggest-item${selected}"
2175 data-index="${i}"
2176 >
2177 <span class="desktop-mode-ai__cmd-icon dashicons ${this._esc(
2178 s.icon ?? "dashicons-arrow-right-alt"
2179 )}" aria-hidden="true"></span>
2180 <span class="desktop-mode-ai__cmd-body">
2181 <span class="desktop-mode-ai__cmd-suggest-label">${this._esc(s.label)}</span>
2182 ${s.description ? `<span class="desktop-mode-ai__cmd-desc">${this._esc(s.description)}</span>` : ""}
2183 </span>
2184 </button>
2185 `;
2186 }).join("");
2187 return `<div class="desktop-mode-ai__cmd-suggest-list">${items}</div>`;
2188 }
2189 /**
2190 * Stable sort used everywhere the palette turns a command list into
2191 * UI: iframe-harvested commands (owner prefix `iframe:`) float to
2192 * the top so contextual Gutenberg / admin commands from the focused
2193 * window read first. Tier-3 loader entries register ahead of tier-2
2194 * statics inside the bridge, so "stable" preserves that ordering
2195 * within the iframe block.
2196 */
2197 _sortCommands(list) {
2198 return list.slice().sort((a, b) => {
2199 const aIframe = typeof a.owner === "string" && a.owner.startsWith("iframe:") ? 0 : 1;
2200 const bIframe = typeof b.owner === "string" && b.owner.startsWith("iframe:") ? 0 : 1;
2201 return aIframe - bIframe;
2202 });
2203 }
2204 /**
2205 * Flip the is-selected class on the command rows without re-rendering
2206 * the whole list. Re-rendering caused two bad effects: (a) fresh DOM
2207 * nodes fired `mouseenter` under the pointer and jumped selection
2208 * back to wherever the mouse was, (b) focus / scroll state was lost.
2209 * Keeping the DOM stable and just flipping a class preserves both.
2210 * Also scrolls the newly-selected row into view for long lists.
2211 */
2212 _paintCommandSelection() {
2213 const items = this._resultsEl.querySelectorAll(".desktop-mode-ai__cmd-item");
2214 items.forEach((el, i) => {
2215 el.classList.toggle("is-selected", i === this._selectedCommand);
2216 });
2217 const list = this._resultsEl.querySelector(".desktop-mode-ai__cmd-list");
2218 if (list) {
2219 list.classList.toggle("desktop-mode-ai__cmd-list--kb-nav", this._keyboardNav);
2220 }
2221 const active = items[this._selectedCommand];
2222 if (active && typeof active.scrollIntoView === "function") {
2223 active.scrollIntoView({ block: "nearest" });
2224 }
2225 }
2226 /** Flip the is-selected class on the suggestion rows without re-rendering the whole list. */
2227 _paintSuggestionSelection() {
2228 this._resultsEl.querySelectorAll(".desktop-mode-ai__cmd-suggest-item").forEach((el, i) => {
2229 el.classList.toggle("is-selected", i === this._selectedSuggestion);
2230 });
2231 }
2232 _renderSuggestions() {
2233 this._resultsEl.hidden = false;
2234 this._resultsEl.innerHTML = `
2235 <div class="desktop-mode-ai__suggestions">
2236 <p class="desktop-mode-ai__suggestions-label">${this._esc("Try asking")}</p>
2237 <div class="desktop-mode-ai__suggestions-list">
2238 ${SUGGESTED_PROMPTS.map(
2239 (p) => `<button type="button" class="desktop-mode-ai__suggestion" data-prompt="${this._esc(p)}">
2240 ${this._esc(p)}
2241 </button>`
2242 ).join("")}
2243 </div>
2244 </div>
2245 `;
2246 this._resultsEl.querySelectorAll(".desktop-mode-ai__suggestion").forEach((btn) => {
2247 btn.addEventListener("click", () => {
2248 const prompt = btn.dataset.prompt ?? "";
2249 this._input.value = prompt;
2250 this._submitBtn.classList.add("has-value");
2251 this._input.focus();
2252 });
2253 });
2254 }
2255 _showThinking(message = "Thinking…") {
2256 this._resultsEl.hidden = false;
2257 this._resultsEl.innerHTML = `
2258 <div class="desktop-mode-ai__state desktop-mode-ai__state--thinking">
2259 ${ICON_SPINNER}
2260 <span>${this._esc(message)}</span>
2261 </div>
2262 `;
2263 }
2264 _showError(message, code) {
2265 this._resultsEl.hidden = false;
2266 if (code === "desktop_mode_ai_disabled") {
2267 const escaped = this._esc(message);
2268 const linkify = (text) => `<button type="button" class="desktop-mode-ai__settings-link">${text}</button>`;
2269 const phrase = /OS Settings.*?Features/;
2270 const withLink = phrase.test(escaped) ? escaped.replace(phrase, (match) => linkify(match)) : `${escaped} ${linkify("Features")}`;
2271 this._resultsEl.innerHTML = `
2272 <div class="desktop-mode-ai__state desktop-mode-ai__state--error">
2273 <span>${withLink}</span>
2274 </div>
2275 `;
2276 this._resultsEl.querySelector(".desktop-mode-ai__settings-link")?.addEventListener("click", () => this._openAssistantSettings());
2277 return;
2278 }
2279 this._resultsEl.innerHTML = `
2280 <div class="desktop-mode-ai__state desktop-mode-ai__state--error">
2281 <span>${this._esc(message)}</span>
2282 </div>
2283 `;
2284 }
2285 _showResult(query, data) {
2286 this._resultsEl.hidden = false;
2287 const messageHtml = `
2288 <div class="desktop-mode-ai__bubble">
2289 <span class="desktop-mode-ai__bubble-icon">${ICON_SPARKLE}</span>
2290 <div class="desktop-mode-ai__bubble-text">${renderMarkdown(data.message || "")}</div>
2291 </div>
2292 `;
2293 let bodyHtml = "";
2294 if (data.answer_type === "entity" && data.entity) {
2295 bodyHtml = this._renderEntityCard(data.entity);
2296 } else if (data.answer_type === "navigation" && data.admin_links && data.admin_links.length > 0) {
2297 bodyHtml = this._renderAdminLinks(data.admin_links);
2298 }
2299 if (data.continue) {
2300 bodyHtml += `
2301 <button type="button" class="desktop-mode-ai__continue-btn"
2302 data-tool="${this._esc(data.continue.tool)}"
2303 data-offset="${data.continue.offset}"
2304 data-query="${this._esc(query)}">
2305 ${this._esc(data.continue.label)}
2306 </button>
2307 `;
2308 }
2309 this._resultsEl.innerHTML = messageHtml + bodyHtml;
2310 this._resultsEl.querySelectorAll(
2311 ".desktop-mode-ai__entity-open"
2312 ).forEach((btn) => {
2313 btn.addEventListener("click", () => {
2314 const url = btn.dataset.url ?? "";
2315 const title = btn.dataset.title ?? "";
2316 const icon = btn.dataset.icon ?? "dashicons-admin-generic";
2317 if (url) {
2318 this._openInLegacyWindow(url, title, icon);
2319 }
2320 });
2321 });
2322 this._resultsEl.querySelectorAll(
2323 ".desktop-mode-ai__admin-link"
2324 ).forEach((btn) => {
2325 btn.addEventListener("click", () => {
2326 const url = btn.dataset.url ?? "";
2327 const title = btn.dataset.title ?? "";
2328 const icon = btn.dataset.icon ?? "dashicons-admin-generic";
2329 if (url) {
2330 this._openInLegacyWindow(url, title, icon);
2331 }
2332 });
2333 });
2334 const cont = this._resultsEl.querySelector(".desktop-mode-ai__continue-btn");
2335 if (cont) {
2336 cont.addEventListener("click", () => {
2337 const tool = cont.dataset.tool ?? null;
2338 const offset = parseInt(cont.dataset.offset ?? "0", 10);
2339 const q = cont.dataset.query ?? query;
2340 this._runSearch(q, tool, offset);
2341 });
2342 }
2343 }
2344 _renderEntityCard(e) {
2345 const isComment = e.type === "comment";
2346 const title = isComment ? `Comment on “${this._esc(e.post_title ?? "post")}` : this._esc(e.title ?? "Untitled");
2347 const summary = this._esc(e.ai_summary || e.excerpt || "");
2348 const typeLabel = e.type.charAt(0).toUpperCase() + e.type.slice(1);
2349 const topicChip = e.topic ? `<span class="desktop-mode-ai__entity-topic">${this._esc(e.topic)}</span>` : "";
2350 let icon;
2351 if (isComment) {
2352 icon = "dashicons-admin-comments";
2353 } else if (e.type === "page") {
2354 icon = "dashicons-admin-page";
2355 } else {
2356 icon = "dashicons-admin-post";
2357 }
2358 return `
2359 <div class="desktop-mode-ai__entity">
2360 <div class="desktop-mode-ai__entity-header">
2361 ${topicChip}
2362 <span class="desktop-mode-ai__entity-type">${this._esc(typeLabel)}</span>
2363 </div>
2364 <h3 class="desktop-mode-ai__entity-title">${title}</h3>
2365 <p class="desktop-mode-ai__entity-summary">${summary}</p>
2366 <button type="button"
2367 class="desktop-mode-ai__entity-open"
2368 data-url="${this._esc(e.edit_url)}"
2369 data-title="${this._esc(e.title ?? e.post_title ?? typeLabel)}"
2370 data-icon="${icon}">
2371 <span>${this._esc(`Open ${typeLabel.toLowerCase()} in desktop`)}</span>
2372 ${ICON_ARROW}
2373 </button>
2374 </div>
2375 `;
2376 }
2377 _renderAdminLinks(links) {
2378 const items = links.map((link) => `
2379 <button type="button"
2380 class="desktop-mode-ai__admin-link"
2381 data-url="${this._esc(link.url)}"
2382 data-title="${this._esc(link.title)}"
2383 data-icon="${this._esc(link.icon)}">
2384 <span class="desktop-mode-ai__admin-link-icon dashicons ${this._esc(link.icon)}" aria-hidden="true"></span>
2385 <span class="desktop-mode-ai__admin-link-body">
2386 <span class="desktop-mode-ai__admin-link-title">${this._esc(link.title)}</span>
2387 <span class="desktop-mode-ai__admin-link-desc">${this._esc(link.description)}</span>
2388 </span>
2389 <span class="desktop-mode-ai__admin-link-arrow">${ICON_ARROW}</span>
2390 </button>
2391 `).join("");
2392 return `<div class="desktop-mode-ai__admin-links">${items}</div>`;
2393 }
2394 /** Minimal HTML escaping for text interpolated into innerHTML. */
2395 _esc(str) {
2396 return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2397 }
2398 // ------------------------------------------------------------------
2399 // DOM scaffold
2400 // ------------------------------------------------------------------
2401 _buildDOM() {
2402 const el = document.createElement("div");
2403 el.id = "desktop-mode-ai-assistant";
2404 el.className = "desktop-mode-ai";
2405 el.setAttribute("role", "dialog");
2406 el.setAttribute("aria-modal", "true");
2407 el.setAttribute("aria-label", "AI Assistant");
2408 el.setAttribute("aria-hidden", "true");
2409 el.setAttribute("hidden", "");
2410 el.innerHTML = `
2411 <div class="desktop-mode-ai__backdrop" aria-hidden="true"></div>
2412 <div class="desktop-mode-ai__panel">
2413 <div class="desktop-mode-ai__header">
2414 <span class="desktop-mode-ai__header-icon">${ICON_SPARKLE}</span>
2415 <span class="desktop-mode-ai__header-label">AI Assistant</span>
2416 <button type="button" class="desktop-mode-ai__close" aria-label="Close">
2417 ${ICON_CLOSE}
2418 </button>
2419 </div>
2420 <div class="desktop-mode-ai__input-wrap">
2421 <span class="desktop-mode-ai__input-icon">${ICON_SPARKLE}</span>
2422 <input
2423 class="desktop-mode-ai__input"
2424 type="text"
2425 placeholder="How can I help?"
2426 autocomplete="off"
2427 spellcheck="false"
2428 aria-label="Ask the AI assistant"
2429 />
2430 <button type="button" class="desktop-mode-ai__submit" aria-label="Send">
2431 ${ICON_RETURN}
2432 </button>
2433 </div>
2434 <div class="desktop-mode-ai__results" hidden></div>
2435 <div class="desktop-mode-ai__footer">
2436 <span class="desktop-mode-ai__footer-hint">
2437 Your assistant for finding content, getting around wp-admin, and more
2438 </span>
2439 <span class="desktop-mode-ai__footer-keys" aria-hidden="true">
2440 <kbd>&#8629;</kbd> ask
2441 </span>
2442 </div>
2443 </div>
2444 `;
2445 return el;
2446 }
2447 }
2448 const factory = (config) => new AiAssistant(config);
2449 window.desktopModeCreateAiAssistant = factory;
2450 })();
2451