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

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