PageSourceSearch

https://littlebirdelectronics.com.au/assets/admin_controllers/admin_voice_agent_controller-8a762a14.js

js littlebirdelectronics.com.au collected 2026-09-25 14:12:01 UTC 96,779 bytes, 2,384 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Module-level singleton — survives Stimulus connect/disconnect.
4//
5// data-turbo-permanent keeps our wrapper in the DOM across Turbo
6// navigations, but in practice Turbo briefly detaches and re-attaches
7// the element during the page swap. Stimulus's MutationObserver fires
8// disconnect then connect, which would tear down the WebRTC peer
9// connection and force the user to start the session again on every
10// navigation.
11//
12// Storing the session here (outside the controller instance) means the
13// pc/dc/micStream/audioEl survive any number of Stimulus
14// reconnections. The new controller instance just rebinds the data
15// channel handler to itself and resumes — same conversation, same
16// activeResponseId, same screen context history.
17//
18// Cleared on actual page unload (the listener below) or when the user
19// explicitly taps stop.
20let SESSION = null
21
22window.addEventListener("beforeunload", () => {
23  if (!SESSION) return
24  try { SESSION.dc?.close() } catch (_) {}
25  try { SESSION.pc?.close() } catch (_) {}
26  SESSION.micStream?.getTracks().forEach((t) => t.stop())
27  if (SESSION.audioEl) { SESSION.audioEl.srcObject = null; SESSION.audioEl.remove() }
28  SESSION = null
29})
30
31// Admin voice assistant.
32//
33// Mounts a big floating mic button on every admin page. Tapping the
34// button opens an OpenAI Realtime session over WebRTC and pipes mic
35// audio in / model audio out. The data channel carries events:
36// transcript deltas, speech-start/stop, and function_call_arguments.done.
37//
38// Function calls are routed two ways:
39//   1. Client-side handlers (`save_form`, `scroll_to_block`, every
40//      `form__*` tool) — DOM tools that don't need a server round-trip.
41//   2. Server-side handlers (every `platform_blocks__*` /
42//      `catalogue__*` / `navigate__*` tool) — POSTed to
43//      /admin/ai-tools/invoke, which dispatches through
44//      AdminAi::ToolRegistry under the admin's tenant.
45//
46// `form__*` tools let the AI fill in any visible form on the page —
47// title fields, textareas, dates, selects, checkboxes — so its reach
48// isn't limited to the platform editor. Every admin form is auto-
49// discovered (collectVisibleForms below), surfaced to the AI in the
50// per-page screen context, and edited via setFormFieldValue with
51// proper input/change events so Turbo and downstream Stimulus
52// controllers see the change as if the user had typed it.
53//
54// The result of every tool call is sent back over the data channel as a
55// function_call_output item, then we trigger response.create so the AI
56// speaks the result.
57//
58// State machine for the visual button:
59//   idle        — not connected, mic off
60//   connecting  — WebRTC handshake in flight
61//   listening   — connected, user can speak
62//   hearing     — VAD detected user speech
63//   thinking    — user finished, waiting for response
64//   speaking    — AI is talking
65//   tool        — AI invoked a tool
66//   error       — handshake or stream failed
67export default class extends Controller {
68  static targets = [
69    "button", "status", "transcript", "indicator", "panel", "copyButton",
70    "composer", "composerInput", "composerSend",
71    "picker", "pickerBackdrop", "pickerList", "pickerTitle", "pickerSubtitle",
72    "pickerSearch", "pickerCount", "pickerFooter", "pickerConfirm", "pickerHint"
73  ]
74  static values = {
75    sessionUrl: { type: String, default: "/admin/openai-sessions" },
76    csrfUrl: { type: String, default: "/admin/csrf-token" },
77    invokeUrl: { type: String, default: "/admin/ai-tools/invoke" }
78  }
79
80  connect() {
81    // Turbo navigation hook: re-collect screen context after each
82    // page load so the AI knows where the user landed.
83    this.boundTurboLoad = () => this.onTurboLoad()
84    document.addEventListener("turbo:load", this.boundTurboLoad)
85
86    if (SESSION && SESSION.pc) {
87      // We're a fresh controller instance taking over an existing
88      // session (Turbo just swapped the page; SESSION survived
89      // because it lives outside the controller).
90      this.adoptSession()
91    } else {
92      // Fresh page, no session yet. Set defaults; user taps the
93      // button to actually open the realtime connection.
94      this.state = "idle"
95      this.aiSpeaking = false
96      this.activeResponseId = null
97      this.pendingResponseCreate = false
98      this.pc = null
99      this.dc = null
100      this.micStream = null
101      this.audioEl = null
102      this.renderState()
103    }
104  }
105
106  disconnect() {
107    if (this.boundTurboLoad) document.removeEventListener("turbo:load", this.boundTurboLoad)
108    // A product picker is page-specific — don't carry a stale one across
109    // a Turbo navigation.
110    this.closePicker()
111
112    // Don't tear down here. Stimulus disconnects this controller every
113    // time Turbo briefly detaches our wrapper during a page swap —
114    // we'd kill the WebRTC session on every navigation. Instead,
115    // snapshot the live state to SESSION (so the next controller
116    // instance can pick it back up) and detach handlers so the dead
117    // controller doesn't get more events.
118    if (this.pc && SESSION) {
119      SESSION.state = this.state
120      SESSION.aiSpeaking = this.aiSpeaking
121      SESSION.activeResponseId = this.activeResponseId
122      SESSION.pendingResponseCreate = this.pendingResponseCreate
123      SESSION.screen = this.screen
124      // Pause events during the gap — the new controller will re-bind
125      // its own handler when it adopts the session.
126      try { if (this.dc) this.dc.onmessage = null } catch (_) {}
127      try { if (this.pc) this.pc.onconnectionstatechange = null } catch (_) {}
128    }
129    // Real page unload is handled by the module-level beforeunload
130    // listener at the top of this file — it tears the session down
131    // for good.
132  }
133
134  // Re-bind UI + handlers to the singleton session that survived
135  // Turbo's element swap. Called from connect() when SESSION already
136  // holds an active peer connection.
137  adoptSession() {
138    this.pc = SESSION.pc
139    this.dc = SESSION.dc
140    this.micStream = SESSION.micStream
141    this.audioEl = SESSION.audioEl
142    this.state = SESSION.state || "listening"
143    this.aiSpeaking = SESSION.aiSpeaking || false
144    this.activeResponseId = SESSION.activeResponseId || null
145    this.pendingResponseCreate = SESSION.pendingResponseCreate || false
146    this.screen = SESSION.screen
147
148    if (this.dc) {
149      this.dc.onmessage = (e) => {
150        try { this.handleEvent(JSON.parse(e.data)) }
151        catch (err) { console.warn("[admin-voice] bad event:", err) }
152      }
153    }
154    if (this.pc) {
155      this.pc.onconnectionstatechange = () => {
156        if (["disconnected", "failed", "closed"].includes(this.pc.connectionState)) {
157          if (this.state !== "idle") this.setState("error", "Connection lost")
158          this.tearDown()
159        }
160      }
161    }
162
163    // The audio element rode along inside our data-turbo-permanent
164    // wrapper, so it still has the remote stream attached. Belt and
165    // suspenders: re-home it under this.element in case anything parked
166    // it elsewhere, and if the brief detach/reattach during the page
167    // swap paused it, nudge it back into play (a no-op if it never
168    // stopped; the .catch swallows an autoplay-policy rejection).
169    if (this.audioEl) {
170      if (this.audioEl.parentNode !== this.element) this.element.appendChild(this.audioEl)
171      if (this.audioEl.paused) { try { this.audioEl.play().catch(() => {}) } catch (_) {} }
172    }
173
174    if (this.hasPanelTarget) this.panelTarget.classList.remove("hidden")
175    this.setStatus(labelForState(this.state))
176    this.renderState()
177
178    // Defensive push: turbo:load normally fires AFTER Stimulus
179    // reconnects (so onTurboLoad below catches it), but in rare cases
180    // the order swaps — there's a brief window between the old
181    // controller's disconnect (which removed its turbo:load listener)
182    // and our connect() below. Re-collect + push now so the model is
183    // always told about the new page exactly once. A duplicate context
184    // message (if turbo:load also fires) is harmless.
185    this.screen = this.collectScreenContext()
186    if (SESSION) SESSION.screen = this.screen
187    if (this.dc?.readyState === "open") {
188      this.pushScreenContextToModel(this.screen)
189    }
190  }
191
192  // Called after Turbo lands on a new page (or the initial page load).
193  // We re-collect screen context and quietly inject it into the
194  // realtime conversation as a system-style note so the AI knows
195  // where it took the user.
196  onTurboLoad() {
197    this.screen = this.collectScreenContext()
198    if (SESSION) SESSION.screen = this.screen
199    if (this.dc && this.dc.readyState === "open") {
200      this.pushScreenContextToModel(this.screen)
201    }
202  }
203
204  // Push a [screen context] item into the realtime conversation.
205  // Doesn't trigger a response — just gives the model fresh facts to
206  // use next time it speaks or calls a tool.
207  pushScreenContextToModel(screen) {
208    if (!this.dc || this.dc.readyState !== "open") return
209    const summary = formatScreenContext(screen)
210    try {
211      this.dc.send(JSON.stringify({
212        type: "conversation.item.create",
213        item: {
214          type: "message",
215          role: "system",
216          content: [{type: "input_text", text: summary}]
217        }
218      }))
219    } catch (err) {
220      console.warn("[admin-voice] failed to push screen context:", err)
221    }
222  }
223
224  // Single button entry point: tap to start, tap again to stop.
225  async toggle() {
226    if (this.state === "idle" || this.state === "error") {
227      await this.start()
228    } else {
229      this.stop()
230    }
231  }
232
233  async start() {
234    if (this.state !== "idle" && this.state !== "error") return
235    this.setState("connecting", "Connecting…")
236    if (this.hasPanelTarget) this.panelTarget.classList.remove("hidden")
237    this.appendTranscript("system", "Connecting to voice assistant…")
238
239    try {
240      this.pc = new RTCPeerConnection()
241
242      // Remote audio = AI voice. Mount the <audio> element INSIDE our
243      // wrapper (#admin-voice-agent), not on <body>. The wrapper is
244      // data-turbo-permanent, so Turbo relocates it — and this audio
245      // child with it — into the new page during a navigation, without
246      // it ever really leaving the document. If we appended to <body>
247      // instead, Turbo's full-body swap would throw the audio element
248      // away on the first navigation (the browser pauses a media element
249      // the moment it's removed from the document), and since pc.ontrack
250      // only fires once, nothing would ever rebind the stream — playback
251      // would stay dead until the user restarted the session. Keeping it
252      // in the permanent wrapper is what makes the AI keep talking across
253      // pages. Hidden so it never affects the FAB's flex layout.
254      this.audioEl = document.createElement("audio")
255      this.audioEl.autoplay = true
256      this.audioEl.hidden = true
257      this.audioEl.dataset.adminVoice = "audio"
258      this.element.appendChild(this.audioEl)
259      this.pc.ontrack = (e) => { this.audioEl.srcObject = e.streams[0] }
260
261      // Mic in
262      this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
263      this.pc.addTrack(this.micStream.getTracks()[0])
264
265      // Data channel for events + tool calls
266      this.dc = this.pc.createDataChannel("oai-events")
267      this.dc.onmessage = (e) => {
268        try { this.handleEvent(JSON.parse(e.data)) }
269        catch (err) { console.warn("[admin-voice] bad event:", err, e.data) }
270      }
271      // Race-safe context push: by the time the channel actually opens,
272      // the user may have already navigated (Turbo) or the DOM may have
273      // mutated (Stimulus / Turbo Frames). Re-collect screen context
274      // here so the very first thing the AI sees over the channel is a
275      // fresh forms+page snapshot — guaranteed to land before any user
276      // audio can produce a response.
277      this.dc.addEventListener("open", () => {
278        try {
279          this.screen = this.collectScreenContext()
280          if (SESSION) SESSION.screen = this.screen
281          this.pushScreenContextToModel(this.screen)
282        } catch (err) {
283          console.warn("[admin-voice] dc.open context push failed:", err)
284        }
285      })
286
287      this.pc.onconnectionstatechange = () => {
288        if (["disconnected", "failed", "closed"].includes(this.pc.connectionState)) {
289          if (this.state !== "idle") this.setState("error", "Connection lost")
290          this.tearDown()
291        }
292      }
293
294      // Offer → server → answer
295      const offer = await this.pc.createOffer()
296      await this.pc.setLocalDescription(offer)
297
298      const csrf = await this.refreshCsrfToken()
299      const screen = this.collectScreenContext()
300      // CloudFront caps each request header around 10KB. Admin pages with
301      // lots of forms (e.g. /admin/suppliers) blow past that and trip a
302      // 494 before the request ever reaches Rails. Encode, measure, and
303      // shed the heaviest fields until it fits. The data channel re-pushes
304      // the full context the moment it opens (see dc.addEventListener
305      // "open" above), so dropping `forms` and `visible_blocks` here only
306      // delays them — it doesn't lose them.
307      const HEADER_BUDGET = 6000
308      let screenForHeader = screen
309      let encoded = encodeURIComponent(JSON.stringify(screenForHeader))
310      if (encoded.length > HEADER_BUDGET && screenForHeader.forms) {
311        const {forms, ...rest} = screenForHeader
312        screenForHeader = rest
313        encoded = encodeURIComponent(JSON.stringify(screenForHeader))
314      }
315      if (encoded.length > HEADER_BUDGET && screenForHeader.visible_blocks) {
316        const {visible_blocks, ...rest} = screenForHeader
317        screenForHeader = rest
318        encoded = encodeURIComponent(JSON.stringify(screenForHeader))
319      }
320      const response = await fetch(this.sessionUrlValue, {
321        method: "POST",
322        credentials: "same-origin",
323        body: offer.sdp,
324        headers: {
325          "Content-Type": "application/sdp",
326          "X-CSRF-Token": csrf,
327          // HTTP header values must be ISO-8859-1. The screen context is
328          // built from page content (product titles, descriptions, form
329          // field values) which routinely contains em-dashes, curly
330          // quotes and accents — passing raw JSON throws "String contains
331          // non ISO-8859-1 code point" in fetch. Percent-encode here;
332          // OpenaiSessionsController#parse_screen decodes it.
333          "X-Voice-Screen": encoded
334        }
335      })
336      if (!response.ok) {
337        const text = await response.text()
338        throw new Error(`Session ${response.status}: ${text}`)
339      }
340      const answer = { type: "answer", sdp: await response.text() }
341      await this.pc.setRemoteDescription(answer)
342
343      this.screen = screen
344      // Promote the session to module scope so it outlives any
345      // Stimulus disconnect/reconnect cycles caused by Turbo.
346      SESSION = {
347        pc: this.pc,
348        dc: this.dc,
349        micStream: this.micStream,
350        audioEl: this.audioEl,
351        screen: this.screen,
352        state: "listening",
353        aiSpeaking: false,
354        activeResponseId: null,
355        pendingResponseCreate: false
356      }
357      this.setState("listening", "Listening — say something")
358      this.appendTranscript("system", "Connected. Try: \"add a featured products block\", \"list the blocks\", \"hide the FAQ\".")
359    } catch (err) {
360      console.error("[admin-voice] start failed:", err)
361      this.setState("error", `Couldn't start voice: ${err.message}`)
362      this.appendTranscript("system", `Error: ${err.message}`)
363      this.tearDown()
364    }
365  }
366
367  stop() {
368    this.setState("idle", "Tap to talk")
369    this.tearDown()
370    this.appendTranscript("system", "Session ended.")
371  }
372
373  tearDown() {
374    this.closePicker()
375    if (this.micStream) {
376      this.micStream.getTracks().forEach((t) => t.stop())
377      this.micStream = null
378    }
379    if (this.dc) { try { this.dc.close() } catch (_) {} this.dc = null }
380    if (this.pc) { try { this.pc.close() } catch (_) {} this.pc = null }
381    if (this.audioEl) {
382      this.audioEl.srcObject = null
383      this.audioEl.remove()
384      this.audioEl = null
385    }
386    // Also clear the module-level singleton so the next start() opens
387    // a fresh connection (and a Turbo navigation in between sees no
388    // session to adopt).
389    SESSION = null
390  }
391
392  // ── Event handler ────────────────────────────────────────────
393
394  handleEvent(event) {
395    switch (event.type) {
396      case "input_audio_buffer.speech_started":
397        // VAD fired: the user is talking. Always reflect this — even
398        // if the AI is currently speaking — so the button visibly
399        // turns green and the user knows they were heard.
400        this.setState("hearing", "Hearing you…")
401        // If the AI is talking, the user wants to interrupt. Cancel
402        // the in-flight response so the model shuts up and listens.
403        if (this.aiSpeaking && this.activeResponseId && this.dc?.readyState === "open") {
404          try {
405            this.dc.send(JSON.stringify({type: "response.cancel"}))
406          } catch (_) { /* ignore */ }
407          this.aiSpeaking = false
408        }
409        break
410
411      case "input_audio_buffer.speech_stopped":
412        this.setState("thinking", "Thinking…")
413        break
414
415      case "response.created":
416        this.activeResponseId = event.response?.id || event.response_id || "active"
417        if (!this.aiSpeaking) {
418          this.aiSpeaking = true
419          this.setState("speaking", "Speaking…")
420        }
421        break
422
423      case "response.output_item.added":
424        if (!this.aiSpeaking) {
425          this.aiSpeaking = true
426          this.setState("speaking", "Speaking…")
427        }
428        break
429
430      case "response.audio_transcript.delta":
431      case "response.output_audio_transcript.delta":
432        if (!this.aiSpeaking) {
433          this.aiSpeaking = true
434          this.setState("speaking", "Speaking…")
435        }
436        this.appendTranscriptDelta(event.delta)
437        break
438
439      case "response.audio_transcript.done":
440      case "response.output_audio_transcript.done":
441        this.finalizeAssistantTranscript()
442        break
443
444      case "conversation.item.input_audio_transcription.delta":
445        // User's own speech transcribed back — useful in the panel.
446        this.appendUserTranscriptDelta(event.delta)
447        break
448
449      case "conversation.item.input_audio_transcription.completed":
450        this.finalizeUserTranscript(event.transcript)
451        break
452
453      case "response.function_call_arguments.done":
454        this.setStatus(`Running ${humanizeName(event.name)}…`)
455        break
456
457      case "response.output_item.done":
458        if (event.item && event.item.type === "function_call" && event.item.status === "completed") {
459          this.executeFunction(event.item.name, event.item.arguments, event.item.call_id)
460        }
461        break
462
463      case "response.done":
464        this.activeResponseId = null
465        this.aiSpeaking = false
466        // If tool outputs landed while a response was in flight, the
467        // model needs a fresh `response.create` to speak the result.
468        // Fire it now, but only once even if multiple outputs queued.
469        if (this.pendingResponseCreate && this.dc?.readyState === "open") {
470          this.pendingResponseCreate = false
471          this.dc.send(JSON.stringify({type: "response.create"}))
472        } else if (this.state === "speaking" || this.state === "tool" || this.state === "thinking") {
473          this.setState("listening", "Listening — say something")
474        }
475        break
476
477      case "output_audio_buffer.stopped":
478        this.aiSpeaking = false
479        if (this.state === "speaking" || this.state === "tool" || this.state === "thinking") {
480          this.setState("listening", "Listening — say something")
481        }
482        break
483
484      case "error":
485        // Benign barge-in race: when the user talks over the assistant
486        // we optimistically send `response.cancel` (see
487        // speech_started above), but the response sometimes finishes
488        // server-side a beat earlier — the API then replies
489        // "response_cancel_not_active". Nothing's broken; the assistant
490        // is already quiet, so don't alarm the user with it.
491        if (event.error?.code === "response_cancel_not_active") {
492          console.debug("[admin-voice] response.cancel raced a finishing response 
492(ignored)")
493          break
494        }
495        console.error("[admin-voice] realtime error:", event)
496        this.appendTranscript("system", `Error: ${event.error?.message || "unknown"}`)
497        break
498    }
499
500    // Mirror response/speech state back to the singleton so the next
501    // controller instance (after a Turbo nav) picks up exactly where
502    // we left off.
503    if (SESSION) {
504      SESSION.aiSpeaking = this.aiSpeaking
505      SESSION.activeResponseId = this.activeResponseId
506      SESSION.pendingResponseCreate = this.pendingResponseCreate
507    }
508  }
509
510  // ── Tool execution ────────────────────────────────────────────
511
512  async executeFunction(name, argsJson, callId) {
513    this.setState("tool", `Running ${humanizeName(name)}…`)
514    let args
515    try { args = JSON.parse(argsJson || "{}") } catch (_) { args = {} }
516
517    // Log the call in the transcript panel (and so it lands in the
518    // copy-to-clipboard dump). The on-screen text is a short preview;
519    // the full arguments are stashed for the clipboard.
520    this.appendToolCall(name, args)
521
522    let result
523    try {
524      const clientHandler = this.clientHandlers[name]
525      if (clientHandler) {
526        result = await clientHandler.call(this, args)
527      } else {
528        // Default path: send to the server registry.
529        const r = await fetch(this.invokeUrlValue, {
530          method: "POST",
531          credentials: "same-origin",
532          headers: {
533            "Content-Type": "application/json",
534            "Accept": "application/json",
535            "X-CSRF-Token": await this.refreshCsrfToken(),
536            "X-Requested-With": "XMLHttpRequest"
537          },
538          body: JSON.stringify({
539            name: name,
540            args: args,
541            tool_call_id: callId,
542            screen: this.screen || this.collectScreenContext()
543          })
544        })
545        result = r.ok
546          ? await r.json()
547          : { ok: false, error: `HTTP ${r.status}` }
548      }
549    } catch (err) {
550      result = { ok: false, error: err.message }
551    }
552
553    this.appendToolResult(name, result)
554    this.handleToolSideEffects(result)
555    this.sendToolResult(callId, result)
556  }
557
558  // Three flavours of side effect:
559  //   navigate: take the user to a new page via Turbo (preserves the
560  //     voice session — voice agent wrapper is data-turbo-permanent).
561  //   refresh_block: a single block was mutated. Surgically update the
562  //     block's row inputs in place (keeps focus/scroll), reveal the
563  //     body, flash-highlight the changed row, and trigger the existing
564  //     platform-editor live-preview update so the right pane repaints.
565  //   refresh_page: a structural change (add/delete/reorder blocks).
566  //     Fall back to softRefreshPage, but preserve which block bodies
567  //     were expanded and re-expand the affected block afterwards.
568  async handleToolSideEffects(result) {
569    if (!result || result.ok === false) return
570
571    // Resource picker — pops a centered modal of matches (products,
572    // collections, suppliers, …) so the user can hand one or many back
573    // to the AI (see showPicker). Newer tool responses send
574    // result.items + result.picker_type; older product-only responses
575    // still send result.products. Either is accepted.
576    const pickerKind = result.picker_type
577    const pickerItems = Array.isArray(result.items) && result.items.length
578      ? result.items
579      : (Array.isArray(result.products) && result.products.length
580        ? result.products
581        : null)
582    if (result.picker && pickerItems) {
583      this.showPicker(pickerItems, {
584        query: result.query,
585        multi: result.picker_mode === "multi",
586        prompt: result.picker_prompt,
587        kind: pickerKind || "product",
588        context: result.picker_context || null
589      })
590      return
591    }
592
593    if (result.navigate) {
594      this.performNavigation(result.navigate)
595      return
596    }
597
598    if (result.refresh_block && Array.isArray(result.items) && result.block_id != null) {
599      this.applyBlockUpdate(result)
600      this.requestPreviewUpdate()
601      return
602    }
603
604    if (result.refresh_page) {
605      await this.softRefreshPage({
606        focusBlockId: result.block_id,
607        focusItemIndex: result.item_index,
608        deletedBlockId: result.deleted_block_id
609      })
610      this.requestPreviewUpdate()
611    }
612  }
613
614  // Navigate via Turbo if it's loaded, falling back to window.location
615  // for cases where Turbo isn't installed (it is, but defensive).
616  performNavigation({ url, action } = {}) {
617    if (action === "back") {
618      window.history.back()
619      return
620    }
621    if (!url) return
622    if (window.Turbo && typeof window.Turbo.visit === "function") {
623      window.Turbo.visit(url)
624    } else {
625      window.location.href = url
626    }
627  }
628
629  // Surgically rewrite a single block's row inputs from the new items
630  // array the server returned. No DOM swap — preserves focus, scroll
631  // position and Stimulus controller state.
632  applyBlockUpdate(result) {
633    const blockEl = document.querySelector(
634      `#platform-blocks [data-block-id="${result.block_id}"]`
635    )
636    if (!blockEl) {
637      // Block isn't on this page (maybe the user navigated). Fall back
638      // to a full refresh so the next mutation lands somewhere sane.
639      this.softRefreshPage({ focusBlockId: result.block_id })
640      return
641    }
642
643    const rowsContainer = blockEl.querySelector('[data-platform-block-target="rows"]')
644    if (!rowsContainer) return
645
646    const rowTemplate = blockEl.querySelector('template[data-platform-block-target="rowTemplate"]')
647    const items = result.items || []
648
649    // Ensure the rows container has the right number of <row> elements.
650    let rowEls = Array.from(rowsContainer.querySelectorAll('[data-platform-block-target="row"]'))
651    while (rowEls.length < items.length && rowTemplate) {
652      const clone = rowTemplate.content.firstElementChild.cloneNode(true)
653      rowsContainer.appendChild(clone)
654      rowEls = Array.from(rowsContainer.querySelectorAll('[data-platform-block-target="row"]'))
655    }
656    while (rowEls.length > items.length) {
657      const last = rowEls.pop()
658      last.remove()
659    }
660
661    // Write each item's field values into its row's inputs.
662    rowEls.forEach((rowEl, i) => {
663      const item = items[i] || {}
664      rowEl.querySelectorAll("[data-field]").forEach((input) => {
665        const key = input.dataset.field
666        const newValue = item[key] != null ? String(item[key]) : ""
667        if (input.value !== newValue) {
668          input.value = newValue
669          input.dispatchEvent(new Event("input", { bubbles: true }))
670          input.dispatchEvent(new Event("change", { bubbles: true }))
671        }
672      })
673    })
674
675    // Update the item count badge in the header.
676    const countEl = blockEl.querySelector(".block-drag-handle")?.parentElement?.querySelector("span:last-of-type")
677    // The schema is: "⋮⋮ <type> <count>". The count is the *third* span.
678    const headerSpans = blockEl.querySelectorAll("button[data-action=\"platform-block#toggle\"] > span")
679    if (headerSpans.length >= 3) {
680      headerSpans[2].textContent = String(items.length)
681    }
682
683    // Reveal the body so the user can see the change, even if the
684    // block was previously collapsed.
685    this.expandBlock(blockEl)
686
687    // Flash-highlight the row that the AI just changed (if known).
688    if (result.item_index) {
689      const targetRow = rowEls[result.item_index - 1]
690      if (targetRow) {
691        targetRow.scrollIntoView({ behavior: "smooth", block: "center" })
692        flashHighlight(targetRow)
693      }
694    } else {
695      // No specific row → highlight the whole block.
696      flashHighlight(blockEl)
697    }
698  }
699
700  expandBlock(blockEl) {
701    const body = blockEl.querySelector('[data-platform-block-target="body"]')
702    if (body) body.classList.remove("hidden")
703  }
704
705  // Ask the existing platform-editor controller to repaint the right
706  // pane. It listens for `input` events on its root element, then POSTs
707  // the inspector form to /preview and swaps the turbo-frame, so a
708  // synthetic input event makes it pick up our surgical row updates
709  // exactly like a user keystroke would.
710  requestPreviewUpdate() {
711    const editorEl = document.querySelector('[data-controller~="platform-editor"]')
712    if (!editorEl) return
713    editorEl.dispatchEvent(new Event("input", { bubbles: true }))
714  }
715
716  sendToolResult(callId, result) {
717    if (!this.dc || this.dc.readyState !== "open") return
718    this.dc.send(JSON.stringify({
719      type: "conversation.item.create",
720      item: {
721        type: "function_call_output",
722        call_id: callId,
723        output: typeof result === "string" ? result : JSON.stringify(result)
724      }
725    }))
726    // If a realtime response is currently being generated, the tool
727    // output is auto-folded into it — DON'T fire another `response.create`
728    // (OpenAI rejects with conversation_already_has_active_response).
729    // For parallel tool calls inside one response, we'll dispatch a single
730    // `response.create` when the active response finishes (see
731    // response.done handler).
732    if (this.activeResponseId) {
733      this.pendingResponseCreate = true
734    } else {
735      this.dc.send(JSON.stringify({type: "response.create"}))
736    }
737  }
738
739  // Inject a user-role message into the live conversation and prompt the
740  // model to respond. Used when the user picks a product from the HUD —
741  // it reads as if they'd said "I picked X" out loud. Same active-response
742  // guard as sendToolResult so we never trip
743  // conversation_already_has_active_response.
744  sendUserMessage(text) {
745    if (!this.dc || this.dc.readyState !== "open") return false
746    this.dc.send(JSON.stringify({
747      type: "conversation.item.create",
748      item: {
749        type: "message",
750        role: "user",
751        content: [{type: "input_text", text: text}]
752      }
753    }))
754    if (this.activeResponseId) {
755      this.pendingResponseCreate = true
756    } else {
757      this.dc.send(JSON.stringify({type: "response.create"}))
758    }
759    return true
760  }
761
762  // ── Resource picker MODAL ────────────────────────────────────────
763  //
764  // A centered, full-screen-backdrop modal listing matches the AI
765  // returned (products, collections, suppliers, …). Two flavours:
766  //   • single-tap — a tool returns `picker: true`. Tapping a row
767  //     feeds that one resource's handle straight back into the
768  //     conversation as if the user had said "I picked X".
769  //   • multi-select — `picker: true, picker_mode: "multi"`. Each row
770  //     gets a checkbox and a footer with "Add selected (N)"; on
771  //     confirm we feed back ALL the ticked items at once.
772  //
773  // The modal supports:
774  //   • text search (filters the in-memory list client-side)
775  //   • ↑/↓ arrow keys to focus rows, Enter to pick/toggle
776  //   • Space to toggle (multi mode)
777  //   • Escape (or backdrop click) to cancel
778  //
779  // The `kind` opt ("product" | "collection" | "supplier") tunes which
780  // fields show in each row — products lead with stock + price, while
781  // collections/suppliers favour descriptive meta over imagery and use
782  // a stacked single-column layout.
783
784  // items: array — see CatalogueTools for the row shape per kind.
785  // opts:
786  //   query?    — what the user originally said (becomes the subtitle)
787  //   multi?    — true for tickbox-style picker
788  //   prompt?   — one-line instruction overrides the default title
789  //   kind?     — "product" (default), "collection", "supplier"
790  //   context?  — opaque hash echoed back to the AI when the user picks
791  //               (e.g. {product_handle: "raspberry-pi-5"} for the
792  //               "suggest collections for THIS product" flow)
793  showPicker(items, opts = {}) {
794    if (!this.hasPickerTarget || !this.hasPickerListTarget) return
795    const o = (typeof opts === "string") ? { query: opts } : (opts || {})
796    const list = (items || []).slice(0, 100)
797    if (!list.length) return
798
799    const multi = !!o.multi
800    const kind = o.kind || "product"
801
802    this.pickerMulti = multi
803    this.pickerKind = kind
804    this.pickerSelection = new Set()
805    this.pickerItems = list
806    this.pickerContext = o.context || null
807    this.pickerFocusIndex = 0
808
809    // Stacked single-column layout reads better for collection /
810    // supplier rows (more meta, less imagery to grid against).
811    this.pickerListTarget.classList.toggle("voice-modal-list-stacked", kind !== "product")
812
813    // Title + subtitle.
814    const prompt = (o.prompt && String(o.prompt).trim()) || ""
815    const noun = pluralizeKind(kind, list.length)
816    if (this.hasPickerTitleTarget) {
817      this.pickerTitleTarget.textContent = prompt || (multi
818        ? `Tick the ${noun} you want`
819        : `Pick a ${singularizeKind(kind)}`)
820    }
821    if (this.hasPickerSubtitleTarget) {
822      const queryBit = o.query ? `"${truncateForSpeech(o.query)}"` : ""
823      const sub = []
824      if (queryBit) sub.push(queryBit)
825      sub.push(`${list.length} ${noun}`)
826      this.pickerSubtitleTarget.textContent = sub.join(" · ")
827    }
828    this.pickerTarget.setAttribute("aria-label", multi
829      ? `${capitalize(noun)} — pick one or more`
830      : `${capitalize(noun)} — pick one`)
831
832    // Renderer per kind. Each row gets a deterministic key so the
833    // search filter can match against a single string fast.
834    this.pickerListTarget.replaceChildren(...list.map((it, idx) => this.buildPickerRow(it, idx, kind, multi)))
835    this.updatePickerCount(list.length, list.length)
836
837    // Pre-select items flagged by the server (e.g. AI-suggested
838    // collections — they start ticked, the user untICKs the ones they
839    // disagree with rather than ticking the ones they want).
840    if (multi) {
841      list.forEach((it, idx) => {
842        if (!it || !it._preselect) return
843        const row = this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${idx}"]`)
844        if (row) this.togglePickerRow(row, it)
845      })
846    }
847
848    // Footer with Cancel + (multi only) "Add selected (N)". The
849    // single-mode footer also serves as a keyboard hint reminder.
850    if (this.hasPickerFooterTarget) {
851      this.pickerFooterTarget.classList.remove("hidden")
852      if (this.hasPickerConfirmTarget) {
853        this.pickerConfirmTarget.style.display = multi ? "" : "none"
854        this.pickerConfirmTarget.disabled = true
855        this.pickerConfirmTarget.textContent = "Add selected"
856      }
857      if (this.hasPickerHintTarget) {
858        this.pickerHintTarget.textContent = multi
859          ? "↑↓ navigate · Space / Enter toggles · Esc to close"
860          : "↑↓ navigate · Enter to pick · Esc to close"
861      }
862    }
863
864    // Reset search box (carrying it over from a previous picker would
865    // surprise the user).
866    if (this.hasPickerSearchTarget) {
867      this.pickerSearchTarget.value = ""
868    }
869
870    // Reveal modal + backdrop.
871    this.pickerTarget.classList.remove("hidden")
872    if (this.hasPickerBackdropTarget) this.pickerBackdropTarget.classList.remove("hidden")
873
874    // Wire up keyboard nav. Stored on `this` so we can detach in closePicker.
875    this.boundPickerKeydown ||= (e) => this.handlePickerKeydown(e)
876    document.addEventListener("keydown", this.boundPickerKeydown)
877
878    // Focus the search input so the user can type immediately. Defer
879    // a tick because the modal is animating in.
880    setTimeout(() => {
881      if (this.hasPickerSearchTarget) this.pickerSearchTarget.focus()
882      this.setFocusedRow(0)
883    }, 30)
884  }
885
886  buildPickerRow(item, index, kind, multi) {
887    const btn = document.createElement("button")
888    btn.type = "button"
889    btn.className = "voice-modal-item"
890    btn.dataset.pickerIndex = String(index)
891    btn.dataset.pickerKey = pickerSearchKey(item, kind)
892    btn.setAttribute("role", multi ? "option" : "button")
893    if (multi) btn.setAttribute("aria-pressed", "false")
894    const itemTitle = item.title || item.handle || item.slug || ""
895    btn.title = multi
896      ? `Toggle "${itemTitle}" in your selection`
897      : `Pick "${itemTitle}"`
898
899    // Faux checkbox for multi-select. Always present in the DOM so the
900    // layout doesn't shift when a row toggles.
901    if (multi) {
902      const box = document.createElement("span")
903      box.className = "voice-modal-check"
904      box.setAttribute("aria-hidden", "true")
905      btn.appendChild(box)
906    }
907
908    // Thumbnail — real image if we have one, fall back to placeholder.
909    let thumb
910    if (item.image_url) {
911      thumb = document.createElement("img")
912      thumb.className = "voice-modal-thumb"
913      thumb.src = item.image_url
914      thumb.alt = ""
915      thumb.loading = "lazy"
916      thumb.addEventListener("error", () => thumb.replaceWith(placeholderModalThumb(kind)))
917    } else {
918      thumb = placeholderModalThumb(kind)
919    }
920    btn.appendChild(thumb)
921
922    // Body — title + meta + (optionally) extra description.
923    const body = document.createElement("div")
924    body.className = "voice-modal-body"
925
926    const titleEl = document.createElement("div")
927    titleEl.className = "voice-modal-item-title"
928    titleEl.textContent = itemTitle
929    body.appendChild(titleEl)
930
931    const metaText = pickerRowMeta(item, kind)
932    if (metaText) {
933      const meta = document.createElement("div")
934      meta.className = "voice-modal-item-meta"
935      if (kind === "product") {
936        const metaBits = [item.vendor, item.product_type].filter((s) => s && String(s).trim())
937        if (metaBits.length) meta.append(document.createTextNode(metaBits.join(" · ")))
938        const skuText = pickerSkuLabel(item)
939        if (skuText) {
940          if (meta.childNodes.length) meta.append(document.createTextNode(" · "))
941          const skuEl = document.createElement("span")
942          skuEl.className = "voice-modal-sku"
943          skuEl.textContent = skuText
944          meta.appendChild(skuEl)
945        }
946        if (!meta.childNodes.length) meta.textContent = item.handle || ""
947      } else {
948        meta.textContent = metaText
949      }
950      body.appendChild(meta)
951    }
952
953    const extraText = pickerRowExtra(item, kind)
954    if (extraText) {
955      const extra = document.createElement("div")
956      extra.className = "voice-modal-item-extra"
957      extra.textContent = extraText
958      body.appendChild(extra)
959    }
960    btn.appendChild(body)
961
962    // Side: price + stock badge for products, count/lead-time chip for
963    // collections/suppliers, nothing for things with no useful side info.
964    const side = pickerRowSide(item, kind)
965    if (side) btn.appendChild(side)
966
967    btn.addEventListener("click", () => {
968      if (multi) this.togglePickerRow(btn, item)
969      else this.pickItem(item)
970    })
971    btn.addEventListener("mousemove", () => {
972      // Track hover as focus too so arrow-key nav + mouse work together.
973      const idx = Number(btn.dataset.pickerIndex)
974      if (!Number.isNaN(idx) && idx !== this.pickerFocusIndex) this.setFocusedRow(idx, false)
975    })
976    return btn
977  }
978
979  // Toggle one row's membership in the multi-select set + repaint its checkbox.
980  togglePickerRow(rowEl, item) {
981    if (!this.pickerSelection) this.pickerSelection = new Set()
982    const nowSelected = !this.pickerSelection.has(item)
983    if (nowSelected) this.pickerSelection.add(item)
984    else this.pickerSelection.delete(item)
985
986    rowEl.setAttribute("aria-pressed", nowSelected ? "true" : "false")
987    const box = rowEl.querySelector(".voice-modal-check")
988    if (box) box.textContent = nowSelected ? "✓" : ""
989    this.updatePickerFooter()
990  }
991
992  updatePickerFooter() {
993    if (!this.hasPickerConfirmTarget) return
994    const n = this.pickerSelection ? this.pickerSelection.size : 0
995    this.pickerConfirmTarget.disabled = n === 0
996    this.pickerConfirmTarget.textContent = n ? `Add selected (${n})` : "Add selected"
997  }
998
999  updatePickerCount(visible, total) {
1000    if (!this.hasPickerCountTarget) return
1001    if (visible === total) {
1002      this.pickerCountTarget.textContent = `${total}`
1003    } else {
1004      this.pickerCountTarget.textContent = `${visible} of ${total}`
1005    }
1006  }
1007
1008  // Text-search filter — applied client-side over `dataset.pickerKey`
1009  // which we built at row-create time. No re-fetch, no server round
1010  // trip; the AI already gave us the candidates.
1011  filterPicker() {
1012    if (!this.hasPickerSearchTarget || !this.hasPickerListTarget) return
1013    const q = this.pickerSearchTarget.value.trim().toLowerCase()
1014    const rows = Array.from(this.pickerListTarget.querySelectorAll(".voice-modal-item"))
1015    let visible = 0
1016    rows.forEach((row) => {
1017      const hit = !q || (row.dataset.pickerKey || "").includes(q)
1018      row.style.display = hit ? "" : "none"
1019      if (hit) visible++
1020    })
1021    this.updatePickerCount(visible, rows.length)
1022
1023    // Empty-state — single span injected into the list so it scrolls
1024    // with the (now-empty) grid.
1025    let empty = this.pickerListTarget.querySelector(".voice-modal-empty")
1026    if (!visible) {
1027      if (!empty) {
1028        empty = document.createElement("div")
1029        empty.className = "voice-modal-empty"
1030        this.pickerListTarget.appendChild(empty)
1031      }
1032      empty.textContent = `No matches for "${this.pickerSearchTarget.value}".`
1033    } else if (empty) {
1034      empty.remove()
1035    }
1036
1037    // Snap focus to the first visible row (cheap; matches what users expect).
1038    const firstVisible = rows.findIndex((row) => row.style.display !== "none")
1039    if (firstVisible >= 0) this.setFocusedRow(firstVisible, false)
1040  }
1041
1042  // Keyboard nav while a row is focused. Search input has its own
1043  // separate handler (pickerSearchKeydown) for first-row jump-out.
1044  handlePickerKeydown(e) {
1045    if (e.key === "Escape") { this.closePicker(); return }
1046    const focusInSearch = document.activeElement === this.pickerSearchTarget
1047    const rows = this.visiblePickerRows()
1048    if (!rows.length) return
1049
1050    if (e.key === "ArrowDown") {
1051      e.preventDefault()
1052      this.setFocusedRow(this.nextVisibleIndex(1))
1053      if (focusInSearch) this.focusedPickerRow()?.focus()
1054    } else if (e.key === "ArrowUp") {
1055      e.preventDefault()
1056      this.setFocusedRow(this.nextVisibleIndex(-1))
1057      if (focusInSearch) this.focusedPickerRow()?.focus()
1058    } else if (e.key === "Home" && !focusInSearch) {
1059      e.preventDefault()
1060      this.setFocusedRow(Number(rows[0].dataset.pickerIndex))
1061      this.focusedPickerRow()?.focus()
1062    } else if (e.key === "End" && !focusInSearch) {
1063      e.preventDefault()
1064      this.setFocusedRow(Number(rows[rows.length - 1].dataset.pickerIndex))
1065      this.focusedPickerRow()?.focus()
1066    } else if (e.key === "Enter" && !focusInSearch) {
1067      e.preventDefault()
1068      const item = this.pickerItems[this.pickerFocusIndex]
1069      if (!item) return
1070      if (this.pickerMulti) {
1071        const row = this.focusedPickerRow()
1072        if (row) this.togglePickerRow(row, item)
1073      } else {
1074        this.pickItem(item)
1075      }
1076    } else if (e.key === "Enter" && focusInSearch) {
1077      // Search box Enter: in single mode picks the first visible row,
1078      // in multi mode toggles + leaves focus on search so the user can
1079      // keep typing.
1080      e.preventDefault()
1081      const item = this.pickerItems[this.pickerFocusIndex]
1082      if (!item) return
1083      if (this.pickerMulti) {
1084        const row = this.focusedPickerRow()
1085        if (row) this.togglePickerRow(row, item)
1086      } else {
1087        this.pickItem(item)
1088      }
1089    } else if (e.key === " " && this.pickerMulti && !focusInSearch) {
1090      e.preventDefault()
1091      const item = this.pickerItems[this.pickerFocusIndex]
1092      const row = this.focusedPickerRow()
1093      if (item && row) this.togglePickerRow(row, item)
1094    }
1095  }
1096
1097  // Allow ↓ inside the search input to jump focus into the list. Stop
1098  // propagation so the document-level handlePickerKeydown listener
1099  // doesn't also advance the focus index a second time.
1100  pickerSearchKeydown(e) {
1101    if (e.key === "ArrowDown") {
1102      e.preventDefault()
1103      e.stopPropagation()
1104      this.focusedPickerRow()?.focus()
1105    }
1106  }
1107
1108  // Clicking the backdrop = cancel.
1109  pickerBackdropClick() {
1110    this.closePicker()
1111  }
1112
1113  visiblePickerRows() {
1114    if (!this.hasPickerListTarget) return []
1115    return Array.from(this.pickerListTarget.querySelectorAll(".voice-modal-item"))
1116      .filter((row) => row.style.display !== "none")
1117  }
1118
1119  focusedPickerRow() {
1120    if (!this.hasPickerListTarget) return null
1121    return this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${this.pickerFocusIndex}"]`)
1122  }
1123
1124  setFocusedRow(idx, scroll = true) {
1125    if (!this.hasPickerListTarget) return
1126    this.pickerListTarget.querySelectorAll(".voice-modal-item-focus").forEach((el) =>
1127      el.classList.remove("voice-modal-item-focus")
1128    )
1129    const row = this.pickerListTarget.querySelector(`.voice-modal-item[data-picker-index="${idx}"]`)
1130    if (!row) return
1131    this.pickerFocusIndex = idx
1132    row.classList.add("voice-modal-item-focus")
1133    if (scroll) row.scrollIntoView({ block: "nearest", inline: "nearest" })
1134  }
1135
1136  nextVisibleIndex(step) {
1137    const rows = this.visiblePickerRows()
1138    if (!rows.length) return this.pickerFocusIndex
1139    const indices = rows.map((r) => Number(r.dataset.pickerIndex))
1140    const current = indices.indexOf(this.pickerFocusIndex)
1141    if (current === -1) return indices[step > 0 ? 0 : indices.length - 1]
1142    const next = current + step
1143    if (next < 0) return indices[0]
1144    if (next >= indices.length) return indices[indices.length - 1]
1145    return indices[next]
1146  }
1147
1148  // Hand back ONE picked resource (single-tap picker) — feeds its
1149  // identifier back into the conversation as if the user had said "I
1150  // picked X". Generalised across products / collections / suppliers.
1151  pickItem(item) {
1152    const kind = this.pickerKind || "product"
1153    const ctx = this.pickerContext
1154    this.closePicker()
1155    if (!item) return
1156
1157    if (!this.dc || this.dc.readyState !== "open") {
1158      this.appendTranscript("system", "Voice session isn't active — tap the mic to restart, then try again.")
1159      return
1160    }
1161    if (this.aiSpeaking && this.activeResponseId) {
1162      try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ }
1163      this.aiSpeaking = false
1164    }
1165
1166    const summary = pickerItemSummary(item, kind)
1167    this.appendTranscript("user", `Picked: ${summary.short}`)
1168    const contextSuffix = ctx ? ` (context: ${JSON.stringify(ctx)})` : ""
1169    this.sendUserMessage(`I picked ${summary.descriptor}: ${summary.long}${contextSuffix}.`)
1170    this.setStatus(`Picked ${summary.short}`)
1171  }
1172
1173  // Back-compat alias — old code path called pickProduct(); some callers
1174  // (tool result handlers, tests) may still reach for the original name.
1175  pickProduct(product) { this.pickItem(product) }
1176
1177  // Hand back ALL the ticked items (multi-select picker) as one
1178  // follow-up message listing each.
1179  confirmPickerSelection() {
1180    const picked = this.pickerSelection ? Array.from(this.pickerSelection) : []
1181    const kind = this.pickerKind || "product"
1182    const ctx = this.pickerContext
1183    this.closePicker()
1184    if (!picked.length) return
1185
1186    if (!this.dc || this.dc.readyState !== "open") {
1187      this.appendTranscript("system", "Voice session isn't active — tap the mic to restart, then try again.")
1188      return
1189    }
1190    if (this.aiSpeaking && this.activeResponseId) {
1191      try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ }
1192      this.aiSpeaking = false
1193    }
1194
1195    const noun = pluralizeKind(kind, picked.length)
1196    const titles = picked.map((p) => pickerItemSummary(p, kind).short)
1197    this.appendTranscript("user", `Picked ${picked.length}: ${titles.join(", ")}`)
1198    const lines = picked.map((p) => pickerItemSummary(p, kind).long)
1199    const contextSuffix = ctx ? ` (context: ${JSON.stringify(ctx)})` : ""
1200    this.sendUserMessage(`I picked ${picked.length} ${noun} from the picker: ${lines.join("; ")}${contextSuffix}. Go ahead with these — use the identifiers exactly.`)
1201    this.setStatus(`Picked ${picked.length} ${noun}`)
1202  }
1203
1204  closePicker() {
1205    if (this.boundPickerKeydown) document.removeEventListener("keydown", this.boundPickerKeydown)
1206    this.pickerSelection = null
1207    this.pickerMulti = false
1208    this.pickerKind = null
1209    this.pickerItems = null
1210    this.pickerContext = null
1211    this.pickerFocusIndex = 0
1212    if (this.hasPickerTarget) this.pickerTarget.classList.add("hidden")
1213    if (this.hasPickerBackdropTarget) this.pickerBackdropTarget.classList.add("hidden")
1214    if (this.hasPickerListTarget) this.pickerListTarget.replaceChildren()
1215    if (this.hasPickerFooterTarget) this.pickerFooterTarget.classList.add("hidden")
1216    if (this.hasPickerSearchTarget) this.pickerSearchTarget.value = ""
1217    if (this.hasPickerCountTarget) this.pickerCountTarget.textContent = ""
1218    if (this.hasPickerSubtitleTarget) this.pickerSubtitleTarget.textContent = ""
1219  }
1220
1221  // ── Text composer ─────────────────────────────────────────────
1222  //
1223  // The voice panel's composer lets the user TYPE to the AI instead of
1224  // (or alongside) speaking. Useful in quiet offices, when the user
1225  // doesn't want voice transcription noise, or when they want to paste
1226  // an exact handle/SKU/URL. Same conversation, either modality.
1227
1228  submitComposer(event) {
1229    if (event) event.preventDefault()
1230    if (!this.hasComposerInputTarget) return
1231    const text = this.composerInputTarget.value
1232    if (!text || !text.trim()) return
1233
1234    if (!this.dc || this.dc.readyState !== "open") {
1235      // Composer should still work — kick off a fresh session, but the
1236      // SDP handshake is async. Tell the user to retry once we're up.
1237      this.appendTranscript("system", "Voice session isn't connected yet. Connecting now — try sending again in a moment.")
1238      this.start()
1239      return
1240    }
1241
1242    // Barge in: if the AI is talking, interrupt so it actually reads
1243    // the typed message instead of finishing its previous sentence.
1244    if (this.aiSpeaking && this.activeResponseId) {
1245      try { this.dc.send(JSON.stringify({type: "response.cancel"})) } catch (_) { /* ignore */ }
1246      this.aiSpeaking = false
1247    }
1248
1249    this.appendTranscript("user", text)
1250    this.sendUserMessage(text)
1251    this.composerInputTarget.value = ""
1252    this.composerResize()
1253  }
1254
1255  composerKeydown(event) {
1256    // Enter sends, Shift+Enter inserts a newline. Standard chat UX.
1257    if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1258      event.preventDefault()
1259      this.submitComposer()
1260    }
1261  }
1262
1263  // Auto-grow the textarea (capped by CSS max-height).
1264  composerResize() {
1265    if (!this.hasComposerInputTarget) return
1266    const el = this.composerInputTarget
1267    el.style.height = "auto"
1268    el.style.height = Math.min(el.scrollHeight, 112) + "px" // 7rem ≈ 112px
1269  }
1270
1271  // Client-only tool handlers. Anything DOM-y goes here so we don't
1272  // round-trip the server for a 1-line action. Server-resident tools
1273  // fall through to the default fetch path.
1274  get clientHandlers() {
1275    return {
1276      save_form: ({ form_id } = {}) => {
1277        const form = form_id ? document.getElementById(form_id) : document.getElementById("platform-form")
1278        if (!form) return { ok: false, error: "No form on this page to save." }
1279        if (typeof form.requestSubmit === "function") form.requestSubmit()
1280        else form.submit()
1281        return { ok: true, markdown: "Saving the page now." }
1282      },
1283      scroll_to_block: ({ block_id }) => {
1284        const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`)
1285        if (!el) return { ok: false, error: "Couldn't find that block on screen." }
1286        el.scrollIntoView({ behavior: "smooth", block: "center" })
1287        flashHighlight(el)
1288        return { ok: true, markdown: "Scrolled to it." }
1289      },
1290      hide_block_ui: ({ block_id }) => {
1291        const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`)
1292        if (!el) return { ok: false, error: "Couldn't find that block on screen." }
1293        el.style.display = "none"
1294        return { ok: true, markdown: "Hidden from the editor (not saved)." }
1295      },
1296      show_block_ui: ({ block_id }) => {
1297        const el = document.querySelector(`#platform-blocks [data-block-id="${block_id}"]`)
1298        if (!el) return { ok: false, error: "Couldn't find that block on screen." }
1299        el.style.display = ""
1300        return { ok: true, markdown: "Visible again." }
1301      },
1302
1303      // ── form__* — auto-discovered form interaction ──
1304      // Every visible <form> on the admin page becomes addressable by
1305      // its id (or a generated data-voice-form-id). Field setters
1306      // dispatch input + change events so Turbo / Stimulus controllers
1307      // pick up the value as if the user had typed it.
1308      form__list: () => {
1309        const forms = collectVisibleForms()
1310        if (forms.length === 0) return { ok: true, forms: [], markdown: "No forms on this page." }
1311        const lines = forms.map((f, i) => `${i + 1}. ${f.label} — id=${f.form_id} (${f.fields.length} fields)`)
1312        return {
1313          ok: true,
1314          forms: forms.map((f) => ({
1315            form_id: f.form_id,
1316            label: f.label,
1317            action: f.action,
1318            field_count: f.fields.length
1319          })),
1320          markdown: `Forms on this page:\n${lines.join("\n")}`
1321        }
1322      },
1323      form__describe: ({ form_id } = {}) => {
1324        const formEl = findFormById(form_id)
1325        if (!formEl) return { ok: false, error: `No form with id "${form_id}" on this page.` }
1326        const fields = collectFormFields(formEl)
1327        const label = inferFormLabel(formEl)
1328        const fieldLines = fields.map((f) => {
1329          const labelBit = f.label && f.label !== f.name ? ` — ${f.label}` : ""
1330          let valueBit = ""
1331          if (f.value !== "" && f.value != null && !(Array.isArray(f.value) && f.value.length === 0)) {
1332            const truncated = JSON.stringify(f.value).slice(0, 80)
1333            valueBit = ` — current: ${truncated}`
1334          }
1335          let optionsBit = ""
1336          if (f.type === "select" || f.type === "select-multiple") {
1337            const opts = (f.options || []).slice(0, 8).map((o) => o.value).join(", ")
1338            optionsBit = ` — options: [${opts}${f.options && f.options.length > 8 ? ", …" : ""}]`
1339          } else if (f.type === "radio") {
1340            optionsBit = ` — options: [${(f.options || []).join(", ")}]`
1341          }
1342          return `- ${f.name} (${f.type})${labelBit}${optionsBit}${valueBit}`
1343        })
1344        return {
1345          ok: true,
1346          form_id: stableFormId(formEl, 0),
1347          label,
1348          action: formEl.getAttribute("action") || "",
1349          method: (formEl.getAttribute("method") || "post").toLowerCase(),
1350          fields,
1351          markdown: `Fields on "${label}":\n${fieldLines.join("\n")}`
1352        }
1353      },
1354      form__set_field: ({ form_id, field, value } = {}) => {
1355        const formEl = findFormById(form_id)
1356        if (!formEl) return { ok: false, error: `No form with id "${form_id}".` }
1357        if (field == null || field === "") return { ok: false, error: "Need a `field` name (or label) to set." }
1358        const result = setFormFieldValue(formEl, field, value)
1359        if (!result.ok) return result
1360        const friendly = result.label || result.name
1361        return {
1362          ok: true,
1363          form_id,
1364          name: result.name,
1365          label: result.label,
1366          value: result.value,
1367          markdown: `Set ${friendly} to ${truncateForSpeech(result.value)}.`
1368        }
1369      },
1370      form__set_fields: ({ form_id, values } = {}) => {
1371        const formEl = findFormById(form_id)
1372        if (!formEl) return { ok: false, error: `No form with id "${form_id}".` }
1373        if (!values || typeof values !== "object" || Array.isArray(values)) {
1374          return { ok: false, error: "`values` must be an object of field-name → value." }
1375        }
1376        const set = []
1377        const failed = []
1378        for (const [field, value] of Object.entries(values)) {
1379          const r = setFormFieldValue(formEl, field, value)
1380          if (r.ok) set.push({ name: r.name, label: r.label, value: r.value })
1381          else failed.push({ field, error: r.error })
1382        }
1383        const md = []
1384        if (set.length) {
1385          const names = set.map((s) => s.label || s.name).join(", ")
1386          md.push(`Set ${set.length} field${set.length === 1 ? "" : "s"}: ${names}.`)
1387        }
1388        if (failed.length) {
1389          const reasons = failed.map((f) => `${f.field} (${f.error})`).join("; ")
1390          md.push(`Couldn't set: ${reasons}.`)
1391        }
1392        return {
1393          ok: set.length > 0,
1394          form_id, set, failed,
1395          markdown: md.join(" ") || "No changes made."
1396        }
1397      },
1398      form__submit: ({ form_id } = {}) => {
1399        const formEl = findFormById(form_id)
1400        if (!formEl) return { ok: false, error: `No form with id "${form_id}".` }
1401        if (typeof formEl.requestSubmit === "function") formEl.requestSubmit()
1402        else formEl.submit()
1403        return { ok: true, form_id, markdown: `Submitting "${inferFormLabel(formEl)}".` }
1404      }
1405    }
1406  }
1407
1408  // ── Screen context ────────────────────────────────────────────
1409
1410  // Collect a small snapshot of what the user is currently looking at —
1411  // the AI uses this to resolve "this", "here", "the third block".
1412  collectScreenContext() {
1413    const ctx = {
1414      page: document.title,
1415      url: window.location.pathname
1416    }
1417
1418    // Platform admin page — the inspector form lives at #platform-form
1419    // and the URL is /admin/platforms/:handle/edit (Platform overrides
1420    // to_param to return its handle, so the route segment is a string,
1421    // not a numeric id).
1422    const platformForm = document.getElementById("platform-form")
1423    if (platformForm) {
1424      const action = platformForm.getAttribute("action") || ""
1425      const m = action.match(/\/admin\/platforms\/([^/?#]+)/)
1426      if (m) {
1427        const segment = decodeURIComponent(m[1])
1428        if (/^\d+$/.test(segment)) {
1429          ctx.platform_id = Number(segment)
1430        } else {
1431          ctx.platform_handle = segment
1432        }
1433        const nameInput = platformForm.querySelector('[name="ecommerce_platform[n
1433ame]"]')
1434        const handleInput = platformForm.querySelector('[name="ecommerce_platform[handle]"]')
1435        if (nameInput) ctx.platform_name = nameInput.value
1436        // Form-supplied handle wins (it may have been edited).
1437        if (handleInput && handleInput.value) ctx.platform_handle = handleInput.value
1438      }
1439    }
1440
1441    // Collection admin page — /admin/collections/:id (numeric id).
1442    // Pull it from the URL so CollectionHeroTools sees the active
1443    // collection without the AI having to pass it in every call.
1444    const cm = window.location.pathname.match(/\/admin\/collections\/(\d+)/)
1445    if (cm) {
1446      ctx.collection_id = Number(cm[1])
1447      const titleEl = document.querySelector("h1, h2") // best-effort
1448      if (titleEl) ctx.collection_title = titleEl.textContent.trim()
1449    }
1450
1451    // Product admin page — /admin/products/:id (numeric id).
1452    const pm = window.location.pathname.match(/\/admin\/products\/(\d+)/)
1453    if (pm) ctx.product_id = Number(pm[1])
1454
1455    // Blog article admin page — /admin/blogs/:blog_id/articles/:article_id(/edit).
1456    // "new" isn't numeric so it won't match. Lets ArticleProductsTools see the
1457    // active article without the AI passing it in every call.
1458    const am = window.location.pathname.match(/\/admin\/blogs\/(\d+)\/articles\/(\d+)/)
1459    if (am) {
1460      ctx.blog_id = Number(am[1])
1461      ctx.article_id = Number(am[2])
1462    }
1463
1464    // List visible blocks so the AI can match positions by content.
1465    const blockEls = document.querySelectorAll('#platform-blocks [data-block-id]')
1466    if (blockEls.length) {
1467      ctx.visible_blocks = Array.from(blockEls).map((el, i) => ({
1468        position: i + 1,
1469        id: Number(el.dataset.blockId),
1470        type: el.dataset.blockType
1471      }))
1472    }
1473
1474    // Auto-discover every visible form on the page so the AI can fill
1475    // them by voice without us hand-coding one tool per controller.
1476    // collectVisibleForms is idempotent — it lazily assigns
1477    // data-voice-form-id so subsequent calls return the same ids.
1478    try {
1479      ctx.forms = collectVisibleForms()
1480    } catch (err) {
1481      console.warn("[admin-voice] form collection failed:", err)
1482      ctx.forms = []
1483    }
1484
1485    return ctx
1486  }
1487
1488  // ── Visual state ────────────────────────────────────────────
1489
1490  setState(state, label) {
1491    this.state = state
1492    if (SESSION) SESSION.state = state
1493    this.setStatus(label)
1494    this.renderState()
1495  }
1496
1497  setStatus(label) {
1498    if (this.hasStatusTarget && label != null) this.statusTarget.textContent = label
1499  }
1500
1501  renderState() {
1502    if (!this.hasButtonTarget) return
1503    const btn = this.buttonTarget
1504    btn.classList.remove(
1505      "voice-idle", "voice-connecting", "voice-listening", "voice-hearing",
1506      "voice-thinking", "voice-speaking", "voice-tool", "voice-error"
1507    )
1508    btn.classList.add(`voice-${this.state}`)
1509    btn.setAttribute("data-state", this.state)
1510    btn.setAttribute("aria-label", labelForState(this.state))
1511    btn.setAttribute("title", labelForState(this.state))
1512  }
1513
1514  // ── Transcript panel ────────────────────────────────────────────
1515
1516  appendTranscript(role, text) {
1517    if (!this.hasTranscriptTarget) return
1518    const row = document.createElement("div")
1519    row.className = `voice-row voice-row-${role}`
1520    row.textContent = text
1521    this.transcriptTarget.appendChild(row)
1522    this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight
1523  }
1524
1525  appendTranscriptDelta(delta) {
1526    if (!this.hasTranscriptTarget || !delta) return
1527    if (!this.currentAssistantRow) {
1528      this.currentAssistantRow = document.createElement("div")
1529      this.currentAssistantRow.className = "voice-row voice-row-assistant"
1530      this.transcriptTarget.appendChild(this.currentAssistantRow)
1531    }
1532    this.currentAssistantRow.textContent += delta
1533    this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight
1534  }
1535
1536  finalizeAssistantTranscript() {
1537    this.currentAssistantRow = null
1538  }
1539
1540  appendUserTranscriptDelta(delta) {
1541    if (!this.hasTranscriptTarget || !delta) return
1542    if (!this.currentUserRow) {
1543      this.currentUserRow = document.createElement("div")
1544      this.currentUserRow.className = "voice-row voice-row-user"
1545      this.transcriptTarget.appendChild(this.currentUserRow)
1546    }
1547    this.currentUserRow.textContent += delta
1548    this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight
1549  }
1550
1551  finalizeUserTranscript(full) {
1552    if (this.currentUserRow && full) this.currentUserRow.textContent = full
1553    this.currentUserRow = null
1554  }
1555
1556  // ── Tool-call logging ──────────────────────────────────────────
1557  //
1558  // Every AI tool invocation gets two transcript rows: one when the
1559  // call goes out (name + a short args preview) and one when it returns
1560  // (ok / error + a short result preview). The on-screen text is kept
1561  // compact; the full arguments / result JSON is stashed on the row's
1562  // dataset so the "Copy" button can include the complete input and
1563  // output in the clipboard dump (handy for bug reports).
1564
1565  appendToolCall(name, args) {
1566    const hasArgs = args && typeof args === "object" && Object.keys(args).length > 0
1567    const preview = hasArgs ? ` ${summarizeForTranscript(args, 220)}` : ""
1568    const full = hasArgs
1569      ? `→ tool call: ${name}\n   args: ${stringifyForClipboard(args)}`
1570      : `→ tool call: ${name}()`
1571    this.appendToolRow("voice-row-tool-call", `→ ${name}${preview}`, full)
1572  }
1573
1574  appendToolResult(name, result) {
1575    const failed = result && typeof result === "object" && result.ok === false
1576    const cls = failed ? "voice-row-tool-result voice-row-tool-error" : "voice-row-tool-result"
1577    let preview
1578    if (failed) {
1579      preview = `error: ${result.error || "failed"}`
1580    } else if (result && typeof result === "object") {
1581      preview = result.markdown || result.copy || result.error || ""
1582      if (!preview) preview = summarizeForTranscript(result, 240)
1583    } else if (result != null) {
1584      preview = String(result)
1585    } else {
1586      preview = "(no result)"
1587    }
1588    const full = `← result (${name}): ${stringifyForClipboard(result)}`
1589    this.appendToolRow(cls, `← ${truncateForTranscript(preview, 240)}`, full)
1590  }
1591
1592  appendToolRow(extraClass, displayText, copyText) {
1593    if (!this.hasTranscriptTarget) return
1594    const row = document.createElement("div")
1595    row.className = `voice-row voice-row-tool ${extraClass}`
1596    row.textContent = displayText
1597    if (copyText) row.dataset.copyText = copyText
1598    this.transcriptTarget.appendChild(row)
1599    this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight
1600  }
1601
1602  togglePanel() {
1603    if (this.hasPanelTarget) this.panelTarget.classList.toggle("hidden")
1604  }
1605
1606  // Copy the whole conversation to the clipboard as plain text — useful
1607  // for pasting the log into a bug report or chat. Tries the async
1608  // Clipboard API first; falls back to a hidden-textarea execCommand
1609  // for insecure contexts where it's unavailable.
1610  async copyTranscript() {
1611    const text = this.transcriptText()
1612    if (!text) { this.flashCopyLabel("Nothing yet"); return }
1613    let copied = false
1614    try {
1615      if (navigator.clipboard?.writeText) {
1616        await navigator.clipboard.writeText(text)
1617        copied = true
1618      }
1619    } catch (_) { /* fall through to the legacy path */ }
1620    if (!copied) copied = this.legacyCopy(text)
1621    this.flashCopyLabel(copied ? "Copied!" : "Copy failed")
1622  }
1623
1624  // Flatten the transcript panel into "Speaker: text" lines. Keeps the
1625  // system breadcrumbs ("Connecting…", errors, "Session ended.") since
1626  // they're handy when handing the log to a developer, and the tool
1627  // rows — for those it uses the full args/result JSON stashed on
1628  // dataset.copyText rather than the truncated on-screen preview.
1629  // Prepends a tiny header with the page URL + time so the log is
1630  // self-describing.
1631  transcriptText() {
1632    if (!this.hasTranscriptTarget) return ""
1633    const lines = []
1634    this.transcriptTarget.querySelectorAll(".voice-row").forEach((row) => {
1635      const display = (row.textContent || "").trim()
1636      const text = (row.dataset.copyText || display).trim()
1637      if (!text) return
1638      if (row.classList.contains("voice-row-user")) lines.push(`You: ${text}`)
1639      else if (row.classList.contains("voice-row-assistant")) lines.push(`Assistant: ${text}`)
1640      else if (row.classList.contains("voice-row-tool")) lines.push(text)
1641      else lines.push(`— ${text}`)
1642    })
1643    if (!lines.length) return ""
1644    return [
1645      "Voice assistant conversation",
1646      window.location.href,
1647      new Date().toString(),
1648      "",
1649      ...lines,
1650      ""
1651    ].join("\n")
1652  }
1653
1654  legacyCopy(text) {
1655    try {
1656      const ta = document.createElement("textarea")
1657      ta.value = text
1658      ta.setAttribute("readonly", "")
1659      ta.style.position = "fixed"
1660      ta.style.top = "-1000px"
1661      ta.style.opacity = "0"
1662      document.body.appendChild(ta)
1663      ta.select()
1664      const ok = document.execCommand("copy")
1665      ta.remove()
1666      return ok
1667    } catch (_) {
1668      return false
1669    }
1670  }
1671
1672  // Brief inline confirmation on the Copy button itself, then revert.
1673  flashCopyLabel(msg) {
1674    if (!this.hasCopyButtonTarget) return
1675    const btn = this.copyButtonTarget
1676    btn.textContent = msg
1677    btn.disabled = true
1678    clearTimeout(this._copyLabelTimer)
1679    this._copyLabelTimer = setTimeout(() => {
1680      btn.textContent = "Copy"
1681      btn.disabled = false
1682    }, 1400)
1683  }
1684
1685  // ── CSRF & soft refresh ────────────────────────────────────────────
1686
1687  csrfToken() {
1688    return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""
1689  }
1690
1691  async refreshCsrfToken() {
1692    try {
1693      const res = await fetch(this.csrfUrlValue, {
1694        method: "GET",
1695        credentials: "same-origin",
1696        headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }
1697      })
1698      if (!res.ok) return this.csrfToken()
1699      const { token } = await res.json()
1700      if (token) {
1701        const meta = document.querySelector('meta[name="csrf-token"]')
1702        if (meta) meta.setAttribute("content", token)
1703        return token
1704      }
1705    } catch (_) { /* fall through */ }
1706    return this.csrfToken()
1707  }
1708
1709  // Re-fetch the current page and morph the inspector + preview turbo
1710  // frame back into the DOM so AI edits become visible without dropping
1711  // the voice session or losing scroll.
1712  //
1713  // Preserves which block bodies were expanded across the refresh and
1714  // re-opens the block the AI just touched.
1715  async softRefreshPage({ focusBlockId, focusItemIndex, deletedBlockId } = {}) {
1716    try {
1717      const expandedIds = new Set()
1718      document.querySelectorAll('#platform-blocks [data-block-id]').forEach((el) => {
1719        const body = el.querySelector('[data-platform-block-target="body"]')
1720        if (body && !body.classList.contains("hidden")) {
1721          expandedIds.add(el.dataset.blockId)
1722        }
1723      })
1724      if (focusBlockId != null) expandedIds.add(String(focusBlockId))
1725      if (deletedBlockId != null) expandedIds.delete(String(deletedBlockId))
1726
1727      const res = await fetch(window.location.href, {
1728        credentials: "same-origin",
1729        headers: { "Accept": "text/html", "X-Requested-With": "XMLHttpRequest" }
1730      })
1731      if (!res.ok) return
1732      const html = await res.text()
1733      const doc = new DOMParser().parseFromString(html, "text/html")
1734
1735      let didSwap = false
1736
1737      // Platform editor pages: swap the blocks list + the live-preview frame.
1738      const newBlocks = doc.getElementById("platform-blocks")
1739      const curBlocks = document.getElementById("platform-blocks")
1740      if (newBlocks && curBlocks) {
1741        curBlocks.replaceChildren(...newBlocks.childNodes)
1742        didSwap = true
1743      }
1744      const newPreview = doc.getElementById("platform_preview")
1745      const curPreview = document.getElementById("platform_preview")
1746      if (newPreview && curPreview) {
1747        curPreview.replaceChildren(...newPreview.childNodes)
1748        didSwap = true
1749      }
1750
1751      // Generic opt-in refresh regions (e.g. the collection page's hero +
1752      // products cards): any element tagged `data-voice-refresh` has its
1753      // children replaced from the freshly fetched HTML, matched by id.
1754      // Stimulus re-binds controllers on the new nodes automatically.
1755      document.querySelectorAll("[data-voice-refresh][id]").forEach((cur) => {
1756        const fresh = doc.getElementById(cur.id)
1757        if (!fresh) return
1758        cur.replaceChildren(...fresh.childNodes)
1759        didSwap = true
1760      })
1761
1762      // Nothing on this page knows how to refresh surgically → fall back to
1763      // a Turbo visit so the AI's change still shows up. The voice agent
1764      // wrapper is data-turbo-permanent, so the session survives; the
1765      // turbo:load handler re-pushes screen context.
1766      if (!didSwap) {
1767        if (window.Turbo && typeof window.Turbo.visit === "function") {
1768          window.Turbo.visit(window.location.href, { action: "replace" })
1769        } else {
1770          window.location.reload()
1771        }
1772        return
1773      }
1774
1775      // Re-expand the blocks that were open before the refresh.
1776      expandedIds.forEach((id) => {
1777        const el = document.querySelector(`#platform-blocks [data-block-id="${id}"]`)
1778        if (el) this.expandBlock(el)
1779      })
1780
1781      // Scroll to + highlight the AI's focus block / row.
1782      if (focusBlockId != null) {
1783        const targetBlock = document.querySelector(
1784          `#platform-blocks [data-block-id="${focusBlockId}"]`
1785        )
1786        if (targetBlock) {
1787          this.expandBlock(targetBlock)
1788          if (focusItemIndex) {
1789            const rows = targetBlock.querySelectorAll('[data-platform-block-target="row"]')
1790            const row = rows[focusItemIndex - 1]
1791            if (row) {
1792              row.scrollIntoView({ behavior: "smooth", block: "center" })
1793              flashHighlight(row)
1794            }
1795          } else {
1796            targetBlock.scrollIntoView({ behavior: "smooth", block: "center" })
1797            flashHighlight(targetBlock)
1798          }
1799        }
1800      }
1801
1802      // Update screen context (e.g. new block ids) for subsequent tools.
1803      this.screen = this.collectScreenContext()
1804    } catch (err) {
1805      console.warn("[admin-voice] soft refresh failed:", err)
1806    }
1807  }
1808}
1809
1810function labelForState(s) {
1811  switch (s) {
1812    case "idle": return "Tap to talk"
1813    case "connecting": return "Connecting…"
1814    case "listening": return "Listening"
1815    case "hearing": return "Hearing you"
1816    case "thinking": return "Thinking…"
1817    case "speaking": return "Speaking…"
1818    case "tool": return "Working…"
1819    case "error": return "Error — tap to retry"
1820    default: return ""
1821  }
1822}
1823
1824function humanizeName(name) {
1825  if (!name) return "something"
1826  return String(name).replace(/^platform_blocks__/, "").replace(/_/g, " ")
1827}
1828
1829// ── Tool-row text helpers ──
1830// One-line, whitespace-collapsed preview of a value for the transcript
1831// panel (object → compact JSON, string → the string), truncated.
1832function summarizeForTranscript(value, max = 200) {
1833  if (value == null) return ""
1834  let s
1835  if (typeof value === "string") {
1836    s = value
1837  } else {
1838    try { s = JSON.stringify(value) } catch (_) { s = String(value) }
1839  }
1840  return truncateForTranscript(s, max)
1841}
1842
1843function truncateForTranscript(text, max = 200) {
1844  const s = String(text == null ? "" : text).replace(/\s+/g, " ").trim()
1845  return s.length > max ? `${s.slice(0, max - 1)}…` : s
1846}
1847
1848// Pretty JSON for the clipboard dump — keeps the FULL args / result so a
1849// pasted log carries the complete tool input and output. Strings pass
1850// through verbatim. Best-effort: never throws.
1851function stringifyForClipboard(value) {
1852  if (value == null) return "null"
1853  if (typeof value === "string") return value
1854  try { return JSON.stringify(value, null, 2) } catch (_) { return String(value) }
1855}
1856
1857function flashHighlight(el) {
1858  const prev = el.style.boxShadow
1859  el.style.transition = "box-shadow 200ms ease-out"
1860  el.style.boxShadow = "0 0 0 3px #6366f1"
1861  setTimeout(() => { el.style.boxShadow = prev }, 1500)
1862}
1863
1864// Brief text dump of the current screen for injection into the
1865// realtime conversation. Keep this short — every system message
1866// counts against context budget.
1867function formatScreenContext(screen) {
1868  if (!screen) return "[screen context] (none)"
1869  const parts = []
1870  parts.push(`url=${screen.url || "?"}`)
1871  if (screen.page) parts.push(`title=${screen.page}`)
1872  if (screen.platform_id) parts.push(`platform_id=${screen.platform_id}`)
1873  if (screen.platform_handle) parts.push(`platform_handle=${screen.platform_handle}`)
1874  if (screen.platform_name) parts.push(`platform_name=${screen.platform_name}`)
1875  if (screen.collection_id) parts.push(`collection_id=${screen.collection_id}`)
1876  if (screen.collection_title) parts.push(`collection_title=${screen.collection_title}`)
1877  if (screen.product_id) parts.push(`product_id=${screen.product_id}`)
1878  if (Array.isArray(screen.visible_blocks) && screen.visible_blocks.length) {
1879    const blocks = screen.visible_blocks
1880      .map((b) => `${b.position}.${b.type}#${b.id}`)
1881      .join(", ")
1882    parts.push(`blocks=[${blocks}]`)
1883  }
1884  if (Array.isArray(screen.forms) && screen.forms.length) {
1885    const formLines = screen.forms.map((f) => {
1886      // Cap each form at 25 fields in the context dump — long settings
1887      // forms can have 50+ inputs. The AI can call form__describe to
1888      // see the full list when it needs to.
1889      const shown = (f.fields || []).slice(0, 25)
1890      const more = (f.fields?.length || 0) > shown.length
1891        ? ` (+${(f.fields.length - shown.length)} more — call form__describe to see all)` : ""
1892      const fieldStr = shown.map((field) => {
1893        const val = field.value
1894        let valBit = ""
1895        if (val !== "" && val != null && !(Array.isArray(val) && val.length === 0)) {
1896          const s = typeof val === "string" ? val.slice(0, 50) : JSON.stringify(val).slice(0, 50)
1897          valBit = `=${JSON.stringify(s)}`
1898        }
1899        return `${field.name}(${field.type})${valBit}`
1900      }).join(", ")
1901      return `  • id=${f.form_id} "${f.label}": ${fieldStr}${more}`
1902    }).join("\n")
1903    parts.push(`forms (${screen.forms.length}):\n${formLines}`)
1904  }
1905  return `[screen context] ${parts.join(" ")}`
1906}
1907
1908// ── Form discovery + manipulation helpers ──
1909//
1910// These run purely in the browser. The matching server-side tool spec
1911// lives in app/services/admin_ai/form_tools.rb so the realtime model
1912// learns about form__list / form__describe / form__set_field /
1913// form__set_fields / form__submit when the session is created — but
1914// every invocation lands in clientHandlers above and routes here.
1915
1916// Walk the DOM for every form the user can interact with right now.
1917// Skips forms inside our own voice overlay, hidden forms, and forms
1918// with no editable fields (e.g. logout buttons whose only input is a
1919// hidden _method).
1920function collectVisibleForms() {
1921  const out = []
1922  const formEls = Array.from(document.querySelectorAll("form"))
1923  for (const formEl of formEls) {
1924    if (formEl.closest("#admin-voice-agent")) continue
1925    if (!isFormUserVisible(formEl)) continue
1926    const fields = collectFormFields(formEl)
1927    if (fields.length === 0) continue
1928    const formId = stableFormId(formEl, out.length)
1929    out.push({
1930      form_id: formId,
1931      label: inferFormLabel(formEl),
1932      action: formEl.getAttribute("action") || "",
1933      method: (formEl.getAttribute("method") || "post").toLowerCase(),
1934      fields
1935    })
1936  }
1937  return out
1938}
1939
1940function isFormUserVisible(formEl) {
1941  if (formEl.hidden) return false
1942  const rect = formEl.getBoundingClientRect()
1943  if (rect.width === 0 && rect.height === 0) return false
1944  let cur = formEl
1945  while (cur && cur !== document.documentElement) {
1946    const cs = window.getComputedStyle(cur)
1947    if (cs.display === "none" || cs.visibility === "hidden") return false
1948    cur = cur.parentElement
1949  }
1950  return true
1951}
1952
1953// Stable per-page id: prefer the form's own id, then any previously
1954// assigned data-voice-form-id, otherwise mint one (cached on the
1955// element so subsequent scans return the same value).
1956function stableFormId(formEl, index) {
1957  if (formEl.id) return formEl.id
1958  if (formEl.dataset.voiceFormId) return formEl.dataset.voiceFormId
1959  const action = (formEl.getAttribute("action") || "form")
1960    .replace(/[^a-z0-9-]/gi, "-")
1961    .replace(/-+/g, "-")
1962    .replace(/^-|-$/g, "")
1963    .slice(-40)
1964  const generated = `voice-form-${index + 1}${action ? `--${action}` : ""}`
1965  formEl.dataset.voiceFormId = generated
1966  return generated
1967}
1968
1969function inferFormLabel(formEl) {
1970  // Heading-based: most admin forms sit under an <h1>. Search the
1971  // ancestor with a heading so we associate "New Article" with the
1972  // article form rather than picking the global page title.
1973  const main = formEl.closest("main") || document.body
1974  const heading = main.querySelector("h1, h2")
1975  if (heading?.textContent?.trim()) return heading.textContent.trim().replace(/\s+/g, " ")
1976  return formEl.getAttribute("aria-label")
1977    || formEl.id
1978    || (formEl.getAttribute("action") || "Form")
1979}
1980
1981// Editable fields only — skip hidden/file/password/submit/CSRF and
1982// anything inside <template> tags.
1983function collectFormFields(formEl) {
1984  const seenRadioGroups = new Set()
1985  const fields = []
1986  const elements = Array.from(formEl.querySelectorAll("input, textarea, select"))
1987  for (const el of elements) {
1988    const desc = describeFormField(el, seenRadioGroups, formEl)
1989    if (desc) fields.push(desc)
1990  }
1991  return fields
1992}
1993
1994function describeFormField(el, seenRadioGroups, formEl) {
1995  if (el.closest("template")) return null
1996  if (el.closest("#admin-voice-agent")) return null
1997  const tag = el.tagName.toLowerCase()
1998  const type = (el.type || tag).toLowerCase()
1999  const name = el.name || el.id
2000  if (!name) return null
2001  if (["hidden", "file", "password", "submit", "button", "reset", "image"].includes(type)) return null
2002  if (["authenticity_token", "_method", "utf8"].includes(name)) return null
2003
2004  const label = labelForFormField(el)
2005
2006  if (type === "radio") {
2007    if (seenRadioGroups.has(name)) return null
2008    seenRadioGroups.add(name)
2009    const group = Array.from(formEl.querySelectorAll(`input[type="radio"][name="${cssEscape(name)}"]`))
2010    return {
2011      name, type: "radio", label,
2012      options: group.map((r) => r.value),
2013      value: group.find((r) => r.checked)?.value || ""
2014    }
2015  }
2016  if (tag === "select") {
2017    return {
2018      name,
2019      type: el.multiple ? "select-multiple" : "select",
2020      label,
2021      options: Array.from(el.options).map((o) => ({ value: o.value, text: (o.text || "").trim() })),
2022      value: el.multiple
2023        ? Array.from(el.selectedOptions).map((o) => o.value)
2024        : el.value
2025    }
2026  }
2027  if (type === "checkbox") {
2028    return { name, type: "checkbox", label, value: !!el.checked }
2029  }
2030  // text, number, email, url, search, tel, date, time, datetime-local,
2031  // week, month, color, range, textarea — all settable as a string.
2032  return { name, type, label, value: el.value || "" }
2033}
2034
2035function labelForFormField(el) {
2036  if (el.id) {
2037    const lab = document.querySelector(`label[for="${cssEscape(el.id)}"]`)
2038    if (lab) return lab.textContent.trim().replace(/\s+/g, " ")
2039  }
2040  const wrap = el.closest("label")
2041  if (wrap) {
2042    const clone = wrap.cloneNode(true)
2043    clone.querySelectorAll("input, select, textarea").forEach((c) => c.remove())
2044    return clone.textContent.trim().replace(/\s+/g, " ")
2045  }
2046  return el.placeholder || el.getAttribute("aria-label") || el.name || ""
2047}
2048
2049function findFormById(formId) {
2050  if (!formId) return null
2051  const safe = String(formId)
2052  const escaped = cssEscape(safe)
2053  // 1. Native id match
2054  let formEl = document.getElementById(safe)
2055  if (formEl && formEl.tagName.toLowerCase() === "form") return formEl
2056  // 2. Generated voice id
2057  formEl = document.querySelector(`form[data-voice-form-id="${escaped}"]`)
2058  if (formEl) return formEl
2059  // 3. Re-scan (lazily assigns voice ids), then retry. Useful when the
2060  //    AI calls form__set_field on a form it heard about in the screen
2061  //    context dump but whose id hasn't been minted in a fresh DOM.
2062  collectVisibleForms()
2063  return document.querySelector(`form[data-voice-form-id="${escaped}"]`) || null
2064}
2065
2066function findFormField(formEl, fieldName) {
2067  if (!formEl || fieldName == null) return null
2068  const target = String(fieldName).trim()
2069  if (!target) return null
2070  const escaped = cssEscape(target)
2071  // 1. Exact name attribute
2072  let el = formEl.querySelector(`[name="${escaped}"]`)
2073  if (el) return el
2074  // 2. id attribute
2075  el = formEl.querySelector(`#${escaped}`)
2076  if (el && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return el
2077  // 3. Label match (case-insensitive, exact then contains)
2078  const lower = target.toLowerCase()
2079  const labels = Array.from(formEl.querySelectorAll("label"))
2080  for (const lab of labels) {
2081    if (lab.textContent.trim().toLowerCase() === lower) {
2082      const found = labelTargetField(lab, formEl)
2083      if (found) return found
2084    }
2085  }
2086  for (const lab of labels) {
2087    if (lab.textContent.trim().toLowerCase().includes(lower)) {
2088      const found = labelTargetField(lab, formEl)
2089      if (found) return found
2090    }
2091  }
2092  return null
2093}
2094
2095function labelTargetField(lab, formEl) {
2096  if (lab.htmlFor) {
2097    const f = formEl.querySelector(`#${cssEscape(lab.htmlFor)}`)
2098    if (f) return f
2099  }
2100  return lab.querySelector("input, textarea, select")
2101}
2102
2103// Set a field's value AND fire input/change so Stimulus controllers,
2104// Turbo morphing, and any inline validation see it as a real edit.
2105function setFormFieldValue(formEl, fieldName, value) {
2106  const el = findFormField(formEl, fieldName)
2107  if (!el) return { ok: false, error: `No field "${fieldName}" on this form.` }
2108  const tag = el.tagName.toLowerCase()
2109  const type = (el.type || tag).toLowerCase()
2110  const label = labelForFormField(el)
2111
2112  if (type === "radio") {
2113    const radios = formEl.querySelectorAll(`input[type="radio"][name="${cssEscape(el.name)}"]`)
2114    const target = Array.from(radios).find((r) => String(r.value) === String(value))
2115    if (!target) return { ok: false, error: `No radio with value "${value}" in group "${el.name}".` }
2116    target.checked = true
2117    fireInputAndChange(target)
2118    flashFormField(target)
2119    return { ok: true, name: el.name, label, value: target.value }
2120  }
2121
2122  if (type === "checkbox") {
2123    const truthy = (value === true || value === "true" || value === 1 || value === "1" || value === "on" || value === "yes")
2124    el.checked = truthy
2125    fireInputAndChange(el)
2126    flashFormField(el)
2127    return { ok: true, name: el.name, label, value: el.checked }
2128  }
2129
2130  if (tag === "select") {
2131    if (el.multiple && Array.isArray(value)) {
2132      const wanted = value.map(String)
2133      Array.from(el.options).forEach((o) => { o.selected = wanted.includes(o.value) })
2134    } else {
2135      el.value = String(value == null ? "" : value)
2136    }
2137    fireInputAndChange(el)
2138    flashFormField(el)
2139    return {
2140      ok: true, name: el.name, label,
2141      value: el.multiple ? Array.from(el.selectedOptions).map((o) => o.value) : el.value
2142    }
2143  }
2144
2145  // Text-like input or textarea.
2146  const newVal = (value == null) ? "" : String(value)
2147  // Use the native value setter so frameworks that watch the property
2148  // descriptor (React etc.) and any setters added by Trix/CodeMirror
2149  // pick up the change.
2150  const proto = tag === "textarea"
2151    ? window.HTMLTextAreaElement.prototype
2152    : window.HTMLInputElement.prototype
2153  const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
2154  if (setter) setter.call(el, newVal); else el.value = newVal
2155  fireInputAndChange(el)
2156  flashFormField(el)
2157  return { ok: true, name: el.name, label, value: el.value }
2158}
2159
2160function fireInputAndChange(el) {
2161  el.dispatchEvent(new Event("input", { bubbles: true }))
2162  el.dispatchEvent(new Event("change", { bubbles: true }))
2163}
2164
2165function flashFormField(el) {
2166  // Visual feedback so the user can see exactly what the AI changed.
2167  // Falls back gracefully if the element doesn't accept inline style.
2168  try {
2169    const prev = el.style.boxShadow
2170    const prevTransition = el.style.transition
2171    el.style.transition = "box-shadow 200ms ease-out"
2172    el.style.boxShadow = "0 0 0 3px rgba(99, 102, 241, 0.55)"
2173    setTimeout(() => {
2174      el.style.boxShadow = prev
2175      el.style.transition = prevTransition
2176    }, 1200)
2177  } catch (_) { /* silent */ }
2178}
2179
2180function truncateForSpeech(value) {
2181  if (value == null) return "(empty)"
2182  if (typeof value === "boolean") return value ? "on" : "off"
2183  const s = typeof value === "string" ? value : JSON.stringify(value)
2184  return s.length > 80 ? `${s.slice(0, 77)}…` : s
2185}
2186
2187function cssEscape(s) {
2188  if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(String(s))
2189  return String(s).replace(/[^\w-]/g, "\\$&")
2190}
2191
2192// ── Product picker rendering helpers ──
2193
2194// Larger placeholder glyph for the modal — kind-tuned so collections
2195// get a folder-y mark, suppliers a building-y one, products the image
2196// placeholder (which is what users already recognise from admin).
2197function placeholderModalThumb(kind) {
2198  const wrap = document.createElement("div")
2199  wrap.className = "voice-modal-thumb"
2200  let svg
2201  if (kind === "collection") {
2202    svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>'
2203  } else if (kind === "supplier") {
2204    svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/></svg>'
2205  } else {
2206    svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>'
2207  }
2208  wrap.innerHTML = svg
2209  return wrap
2210}
2211
2212// "SKU PI5-8GB" or "SKU PI5-8GB +2 more" for multi-variant products.
2213function pickerSkuLabel(product) {
2214  const skus = Array.isArray(product.skus) ? product.skus : (product.sku ? [product.sku] : [])
2215  if (!skus.length) return ""
2216  const extra = skus.length > 1 ? ` +${skus.length - 1} more` : ""
2217  return `SKU ${skus[0]}${extra}`
2218}
2219
2220// Colour-coded stock pill. Mirrors CatalogueTools#stock_label server-side.
2221function pickerQtyBadge(product) {
2222  if (!product.tracked) return {cls: "voice-modal-qty-na", text: "Untracked"}
2223  const local = Number(product.available || 0)
2224  if (local > 0) return {cls: local <= 5 ? "voice-modal-qty-low" : "voice-modal-qty-in", text: `${local} in stock`}
2225  if (product.always_available) return {cls: "voice-modal-qty-in", text: "In stock"}
2226  const supplier = Number(product.supplier_available || 0)
2227  if (supplier > 0) return {cls: "voice-modal-qty-low", text: `${supplier} at supplier`}
2228  return {cls: "voice-modal-qty-out", text: "Out of stock"}
2229}
2230
2231function formatPrice(value) {
2232  const n = Number(value)
2233  if (!isFinite(n)) return ""
2234  return `$${n.toFixed(2)}`
2235}
2236
2237// ── Picker row helpers (per-kind formatting) ─────────────────────
2238//
2239// Each kind has its own idea of what "the headline meta" and "side info"
2240// look like — products lead with stock/price, collections with how many
2241// products they hold, suppliers with website + lead time. Centralising
2242// the dispatch here keeps buildPickerRow tidy.
2243
2244function singularizeKind(kind) {
2245  switch (kind) {
2246    case "collection": return "collection"
2247    case "supplier":   return "supplier"
2248    default:           return "product"
2249  }
2250}
2251
2252function pluralizeKind(kind, n) {
2253  const base = singularizeKind(kind)
2254  return n === 1 ? base : (base + "s")
2255}
2256
2257function capitalize(s) {
2258  if (!s) return ""
2259  return s.charAt(0).toUpperCase() + s.slice(1)
2260}
2261
2262// One precomputed lowercase string per row that filterPicker matches
2263// the search box against. Pulling all the searchable fields into a
2264// single haystack means a tight loop with `.includes()` filters
2265// hundreds of rows without breaking a sweat.
2266function pickerSearchKey(item, kind) {
2267  const bits = [item.title, item.handle, item.slug]
2268  if (kind === "product") {
2269    bits.push(item.vendor, item.product_type, item.sku)
2270    if (Array.isArray(item.skus)) bits.push(...item.skus)
2271  } else if (kind === "collection") {
2272    bits.push(item.parent_title, item.parent_handle, item.description)
2273  } else if (kind === "supplier") {
2274    bits.push(item.website_url, item.contact_email, item.domain)
2275  }
2276  return bits.filter((s) => s != null && s !== "").join(" ").toLowerCase()
2277}
2278
2279// First-line meta — the "vendor · type · SKU" bit under the title.
2280// (Used as a presence-check by buildPickerRow; products are special-cased
2281// there because they get inline DOM nodes for the SKU monospace span.)
2282function pickerRowMeta(item, kind) {
2283  if (kind === "product") {
2284    const bits = [item.vendor, item.product_type, pickerSkuLabel(item)].filter((s) => s && String(s).trim())
2285    return bits.join(" · ") || item.handle || ""
2286  }
2287  if (kind === "collection") {
2288    const bits = []
2289    if (item.parent_title) bits.push(`under ${item.parent_title}`)
2290    if (typeof item.product_count === "number") {
2291      bits.push(`${item.product_count} ${item.product_count === 1 ? "product" : "products"}`)
2292    }
2293    if (item.handle) bits.push(`/collections/${item.handle}`)
2294    return bits.join(" · ")
2295  }
2296  if (kind === "supplier") {
2297    const bits = []
2298    if (item.product_count != null) bits.push(`${item.product_count} ${item.product_count === 1 ? "product" : "products"}`)
2299    if (item.dispatch_lead_time_max) {
2300      const min = item.dispatch_lead_time_min
2301      const max = item.dispatch_lead_time_max
2302      const range = (min && min !== max) ? `${min}–${max}` : `${max}`
2303      bits.push(`dispatch ${range} ${max === 1 ? "day" : "days"}`)
2304    }
2305    if (item.website_url) bits.push(item.website_url.replace(/^https?:\/\//, ""))
2306    return bits.join(" · ") || item.slug || ""
2307  }
2308  return ""
2309}
2310
2311// Optional second line under the meta — e.g. a curation guide preview
2312// for a collection, or a contact email for a supplier. Truncated by
2313// CSS line-clamp.
2314function pickerRowExtra(item, kind) {
2315  if (kind === "collection") {
2316    return (item.description || item.curation_guide || "").toString().trim()
2317  }
2318  if (kind === "supplier") {
2319    return (item.contact_email || "").toString().trim()
2320  }
2321  return ""
2322}
2323
2324// Side panel (price / stock pill / count chip) — only products have a
2325// "natural" side panel; collections + suppliers just show a subtle
2326// count badge when one is supplied.
2327function pickerRowSide(item, kind) {
2328  if (kind === "product") {
2329    const side = document.createElement("div")
2330    side.className = "voice-modal-item-side"
2331    if (item.price != null && item.price !== "") {
2332      const price = document.createElement("span")
2333      price.className = "voice-modal-price"
2334      price.textContent = formatPrice(item.price)
2335      side.appendChild(price)
2336    }
2337    const qty = pickerQtyBadge(item)
2338    const qtyEl = document.createElement("span")
2339    qtyEl.className = `voice-modal-qty ${qty.cls}`
2340    qtyEl.textContent = qty.text
2341    side.appendChild(qtyEl)
2342    return side
2343  }
2344  if (kind === "collection" && typeof item.product_count === "number") {
2345    const side = document.createElement("div")
2346    side.className = "voice-modal-item-side"
2347    const chip = document.createElement("span")
2348    chip.className = `voice-modal-qty ${item.product_count > 0 ? "voice-modal-qty-in" : "voice-modal-qty-na"}`
2349    chip.textContent = `${item.product_count}`
2350    side.appendChild(chip)
2351    return side
2352  }
2353  return null
2354}
2355
2356// The string we drop back into the conversation when the user picks
2357// this item. `short` shows in the transcript ("Picked: X"); `long`
2358// goes to the model and includes the handle/slug so it can act
2359// without ambiguity.
2360function pickerItemSummary(item, kind) {
2361  const title = item.title || item.handle || item.slug || ""
2362  if (kind === "collection") {
2363    return {
2364      descriptor: "collection",
2365      short: title,
2366      long: `"${title}" (handle: ${item.handle || ""})`
2367    }
2368  }
2369  if (kind === "supplier") {
2370    const slug = item.slug || item.handle || ""
2371    return {
2372      descriptor: "supplier",
2373      short: title,
2374      long: `"${title}" (slug: ${slug})`
2375    }
2376  }
2377  // Product (default)
2378  const sku = item.sku ? `, SKU: ${item.sku}` : ""
2379  return {
2380    descriptor: "product",
2381    short: title,
2382    long: `"${title}" (handle: ${item.handle || ""}${sku})`
2383  }
2384}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.