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

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