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

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

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