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

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