PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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.6, at assets/js/ai-assistant.js

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