studio-engine 0.130.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +20 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// app for it (GET /agent_chat/turns, POST /agent_chat/messages), which proxies.
|
|
4
4
|
//
|
|
5
5
|
// The look is the ERB: each turn is a clone of a <template data-agent-chat-template>
|
|
6
|
-
// (studio/agent_chat/_turn), filled with
|
|
6
|
+
// (studio/agent_chat/_turn), filled with text nodes, never HTML (MARKDOWN below).
|
|
7
7
|
//
|
|
8
8
|
// LOAD on connect, the thread's latest turns; a failure shows the offline
|
|
9
9
|
// notice with Try again (and retries itself, slowly).
|
|
@@ -38,6 +38,23 @@
|
|
|
38
38
|
// that still reports it as "failed" gets the failed line.
|
|
39
39
|
// TYPING the typing line shows while the hub says an answer is pending:
|
|
40
40
|
// "thinking", then "typing" once a turn's status is "typing".
|
|
41
|
+
// OPENER data-agent-chat-opener on the section: words the chat sends as the
|
|
42
|
+
// visitor's first message once its thread has loaded, so the agent
|
|
43
|
+
// starts answering at once (studio/talk_to_agent sets it as its modal
|
|
44
|
+
// opens). Sent once per data-agent-chat-opener-key per tab
|
|
45
|
+
// (sessionStorage): opening the same topic again shows the thread and
|
|
46
|
+
// sends nothing. A send the app refuses puts the words in the box.
|
|
47
|
+
// A visitor who sends before the thread loads gets the opener posted
|
|
48
|
+
// first, then their words; it is never dropped and never sent twice.
|
|
49
|
+
// MARKDOWN an agent turn's words show a small, safe Markdown subset (bold,
|
|
50
|
+
// italics, code, http(s) links, lists, line breaks), built as nodes,
|
|
51
|
+
// never HTML; a visitor's words stay plain text (markdownBlocks).
|
|
52
|
+
// TYPE-IN data-agent-chat-reveal="true" on the section: an agent answer that
|
|
53
|
+
// arrives while the chat is open types itself in (revealPlan, about
|
|
54
|
+
// REVEAL_CPS characters a second, REVEAL_MIN_MS to REVEAL_MAX_MS), as
|
|
55
|
+
// the hub's agent_reveal does. The thread loaded on open, and every
|
|
56
|
+
// answer under prefers-reduced-motion, shows at once. While it types
|
|
57
|
+
// the log is aria-busy, so a screen reader hears the answer once.
|
|
41
58
|
//
|
|
42
59
|
// It imports nothing, so test/javascript/agent_chat.test.mjs loads it as a
|
|
43
60
|
// data: module; studio/controllers/agent_chat_controller mounts it.
|
|
@@ -56,6 +73,11 @@ export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
|
|
|
56
73
|
const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
|
|
57
74
|
export const REFRESH_EVENT = "studio:agent-chat:refresh"
|
|
58
75
|
export const SENT_EVENT = "studio:agent-chat:sent"
|
|
76
|
+
export const OPENER_STORAGE_PREFIX = "studio:agent-chat:opener:"
|
|
77
|
+
export const REVEAL_CPS = 420
|
|
78
|
+
export const REVEAL_MIN_MS = 450
|
|
79
|
+
export const REVEAL_MAX_MS = 2600
|
|
80
|
+
export const REVEAL_FRAME_MS = 32
|
|
59
81
|
const UNSETTLED = new Set(["pending", "typing"])
|
|
60
82
|
|
|
61
83
|
export function settled(turn) {
|
|
@@ -127,6 +149,156 @@ export function csrfToken(form, doc = globalThis.document) {
|
|
|
127
149
|
return meta ? meta.getAttribute("content") : null
|
|
128
150
|
}
|
|
129
151
|
|
|
152
|
+
// How a new answer of `length` characters types in: its duration in ms, clamped
|
|
153
|
+
// to REVEAL_MIN_MS..REVEAL_MAX_MS, and how many characters show `elapsed` ms in.
|
|
154
|
+
export function revealPlan(length, elapsed) {
|
|
155
|
+
const total = Math.max(0, Number(length) || 0)
|
|
156
|
+
const duration = Math.min(REVEAL_MAX_MS, Math.max(REVEAL_MIN_MS, Math.round((total / REVEAL_CPS) * 1000)))
|
|
157
|
+
const shown = elapsed >= duration ? total : Math.floor((total * Math.max(0, elapsed)) / duration)
|
|
158
|
+
return { duration, shown }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The sessionStorage key an opener is remembered under; null with no key.
|
|
162
|
+
export function openerStorageKey(key) {
|
|
163
|
+
const name = String(key ?? "").trim()
|
|
164
|
+
return name ? `${OPENER_STORAGE_PREFIX}${name}` : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// sessionStorage, or null where the browser refuses it (a private window, a
|
|
168
|
+
// sandboxed frame): the opener is then sent on every open.
|
|
169
|
+
function sessionStore() {
|
|
170
|
+
try { return globalThis.sessionStorage || null } catch (_error) { return null }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function prefersReducedMotion() {
|
|
174
|
+
try { return Boolean(globalThis.matchMedia && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches) } catch (_error) { return false }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MARKDOWN: an agent turn's words as a small, safe subset of Markdown, for agent
|
|
178
|
+
// turns only (a visitor's words stay plain text). markdownBlocks is pure: it turns
|
|
179
|
+
// the words into paragraphs and lists of inline pieces. renderMarkdown builds them
|
|
180
|
+
// as DOM nodes with createElement and text nodes, never as HTML, so any other
|
|
181
|
+
// markup in the words shows as the characters it is.
|
|
182
|
+
//
|
|
183
|
+
// **bold** __bold__ *italics* `code` [label](https://…)
|
|
184
|
+
// "- ", "* ", "+ " and "1. " lines make a list; a leading "> " is dropped;
|
|
185
|
+
// any other line break stays a line break.
|
|
186
|
+
//
|
|
187
|
+
// A link's target must be http: or https: (SAFE_LINK); anything else shows as
|
|
188
|
+
// its own text. No _underscore_ italics: snake_case words are common in answers,
|
|
189
|
+
// and the regex stays free of lookbehind, which older Safari cannot parse.
|
|
190
|
+
export const SAFE_LINK = /^https?:\/\/[^\s<>"'`]+$/i
|
|
191
|
+
const INLINE = /\*\*([^\s*](?:[^\n]*?[^\s*])?)\*\*|__([^\s_](?:[^\n]*?[^\s_])?)__|\[([^\]\n]+)\]\(([^()\s]+)\)|`([^`\n]+)`|\*([^*\s](?:[^*\n]*?[^*\s])?)\*/g
|
|
192
|
+
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/
|
|
193
|
+
const NUMBERED = /^\s{0,3}\d{1,3}[.)]\s+(.*)$/
|
|
194
|
+
const QUOTE = /^\s{0,3}>\s?/
|
|
195
|
+
|
|
196
|
+
export function safeHref(url) {
|
|
197
|
+
const href = String(url ?? "").trim()
|
|
198
|
+
if (!SAFE_LINK.test(href)) return null
|
|
199
|
+
try {
|
|
200
|
+
const parsed = new URL(href)
|
|
201
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? href : null
|
|
202
|
+
} catch (_error) { return null }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Inline pieces: {text} | {strong: [...]} | {em: [...]} | {code} | {link, href}.
|
|
206
|
+
export function markdownInline(text) {
|
|
207
|
+
const pieces = []
|
|
208
|
+
const words = String(text ?? "")
|
|
209
|
+
let at = 0
|
|
210
|
+
// A fresh regex per call: bold and italics recurse, and a shared /g regex's
|
|
211
|
+
// lastIndex would jump.
|
|
212
|
+
const inline = new RegExp(INLINE.source, "g")
|
|
213
|
+
for (let match = inline.exec(words); match; match = inline.exec(words)) {
|
|
214
|
+
const [whole, bold, bold2, label, url, code, em] = match
|
|
215
|
+
let piece = null
|
|
216
|
+
if (bold != null || bold2 != null) piece = { strong: markdownInline(bold ?? bold2) }
|
|
217
|
+
else if (label != null) { const href = safeHref(url); if (href) piece = { link: label, href } }
|
|
218
|
+
else if (code != null) piece = { code }
|
|
219
|
+
else piece = { em: markdownInline(em) }
|
|
220
|
+
if (!piece) continue
|
|
221
|
+
if (match.index > at) pieces.push({ text: words.slice(at, match.index) })
|
|
222
|
+
pieces.push(piece)
|
|
223
|
+
at = match.index + whole.length
|
|
224
|
+
}
|
|
225
|
+
if (at < words.length) pieces.push({ text: words.slice(at) })
|
|
226
|
+
return pieces
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Blocks: {lines: [inline, ...]} for a run of text lines, {list: "ul"|"ol", items: [inline, ...]}.
|
|
230
|
+
export function markdownBlocks(text) {
|
|
231
|
+
const blocks = []
|
|
232
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
233
|
+
const line = raw.replace(QUOTE, "")
|
|
234
|
+
const bullet = line.match(BULLET)
|
|
235
|
+
const numbered = bullet ? null : line.match(NUMBERED)
|
|
236
|
+
const kind = bullet ? "ul" : numbered ? "ol" : null
|
|
237
|
+
const last = blocks.at(-1)
|
|
238
|
+
if (kind) {
|
|
239
|
+
const item = markdownInline((bullet || numbered)[1])
|
|
240
|
+
if (last && last.list === kind) last.items.push(item)
|
|
241
|
+
else blocks.push({ list: kind, items: [item] })
|
|
242
|
+
} else if (last && last.lines) {
|
|
243
|
+
last.lines.push(markdownInline(line))
|
|
244
|
+
} else {
|
|
245
|
+
blocks.push({ lines: [markdownInline(line)] })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return blocks
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inlineNodes(d, pieces) {
|
|
252
|
+
return pieces.map((piece) => {
|
|
253
|
+
if (piece.text != null) return d.createTextNode(piece.text)
|
|
254
|
+
if (piece.code != null) { const el = d.createElement("code"); el.textContent = piece.code; return el }
|
|
255
|
+
if (piece.link != null) {
|
|
256
|
+
const el = d.createElement("a")
|
|
257
|
+
el.setAttribute("href", piece.href)
|
|
258
|
+
el.setAttribute("target", "_blank")
|
|
259
|
+
el.setAttribute("rel", "noopener noreferrer nofollow")
|
|
260
|
+
el.style.textDecoration = "underline"
|
|
261
|
+
el.textContent = piece.link
|
|
262
|
+
return el
|
|
263
|
+
}
|
|
264
|
+
const el = d.createElement(piece.strong ? "strong" : "em")
|
|
265
|
+
el.append(...inlineNodes(d, piece.strong || piece.em))
|
|
266
|
+
return el
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fills `el` with the words as Markdown nodes. Where the element cannot build
|
|
271
|
+
// nodes (no document), the words go in as text, as before.
|
|
272
|
+
export function renderMarkdown(el, words, doc = globalThis.document) {
|
|
273
|
+
const d = (el && el.ownerDocument) || doc
|
|
274
|
+
if (!el || !d || typeof d.createElement !== "function" || typeof el.replaceChildren !== "function") {
|
|
275
|
+
if (el) el.textContent = String(words ?? "")
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
const nodes = []
|
|
279
|
+
for (const block of markdownBlocks(words)) {
|
|
280
|
+
if (block.list) {
|
|
281
|
+
const list = d.createElement(block.list)
|
|
282
|
+
list.style.paddingLeft = "1.25rem"
|
|
283
|
+
list.style.listStyle = block.list === "ul" ? "disc" : "decimal"
|
|
284
|
+
for (const item of block.items) {
|
|
285
|
+
const li = d.createElement("li")
|
|
286
|
+
li.append(...inlineNodes(d, item))
|
|
287
|
+
list.append(li)
|
|
288
|
+
}
|
|
289
|
+
nodes.push(list)
|
|
290
|
+
} else {
|
|
291
|
+
// The text keeps its own line breaks (the turn's text is pre-wrap); a list
|
|
292
|
+
// sits on its own lines, so no break is added next to one.
|
|
293
|
+
block.lines.forEach((line, index) => {
|
|
294
|
+
if (index > 0) nodes.push(d.createTextNode("\n"))
|
|
295
|
+
nodes.push(...inlineNodes(d, line))
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
el.replaceChildren(...nodes)
|
|
300
|
+
}
|
|
301
|
+
|
|
130
302
|
export function turnsUrl(base, { soul, after, limit = LIMIT }) {
|
|
131
303
|
const params = new URLSearchParams({ soul, limit: String(limit) })
|
|
132
304
|
if (after != null) params.set("after", after)
|
|
@@ -139,7 +311,9 @@ export function mountAgentChat(root, {
|
|
|
139
311
|
now = () => Date.now(),
|
|
140
312
|
setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
141
313
|
clearTimer = (id) => globalThis.clearTimeout(id),
|
|
142
|
-
CustomEventClass = globalThis.CustomEvent
|
|
314
|
+
CustomEventClass = globalThis.CustomEvent,
|
|
315
|
+
storage = sessionStore(),
|
|
316
|
+
reducedMotion = prefersReducedMotion
|
|
143
317
|
} = {}) {
|
|
144
318
|
const $ = (selector) => root.querySelector(selector)
|
|
145
319
|
const log = $("[data-agent-chat-log]")
|
|
@@ -154,8 +328,11 @@ export function mountAgentChat(root, {
|
|
|
154
328
|
const status = $("[data-agent-chat-status]")
|
|
155
329
|
const soul = root.dataset.agentChatSoul
|
|
156
330
|
const name = root.dataset.agentChatName || "The agent"
|
|
331
|
+
const opener = String(root.dataset.agentChatOpener || "").trim()
|
|
332
|
+
const openerKey = openerStorageKey(root.dataset.agentChatOpenerKey)
|
|
333
|
+
const typeIn = root.dataset.agentChatReveal === "true"
|
|
157
334
|
|
|
158
|
-
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
|
|
335
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false, loaded: false, typing: new Map(), openerPhase: "owed", openerPost: null }
|
|
159
336
|
|
|
160
337
|
const onScroll = () => { state.following = isFollowing(log) }
|
|
161
338
|
log.addEventListener("scroll", onScroll, { passive: true })
|
|
@@ -237,7 +414,14 @@ export function mountAgentChat(root, {
|
|
|
237
414
|
}
|
|
238
415
|
node.dataset.status = turn.status || ""
|
|
239
416
|
const text = node.querySelector("[data-agent-chat-text]")
|
|
240
|
-
|
|
417
|
+
const words = String(turn.text ?? "")
|
|
418
|
+
const agent = turn.role === "agent"
|
|
419
|
+
if (text && state.typing.has(key)) {
|
|
420
|
+
if (state.typing.get(key).words !== words) { stopTyping(key); showWords(text, words, agent) }
|
|
421
|
+
} else if (text && shownWords(text, agent) !== words) {
|
|
422
|
+
if (typeIn && state.loaded && agent && words && !reducedMotion()) startTyping(key, text, words)
|
|
423
|
+
else showWords(text, words, agent)
|
|
424
|
+
}
|
|
241
425
|
const failed = node.querySelector("[data-agent-chat-failed]")
|
|
242
426
|
if (failed) failed.hidden = turn.status !== "failed"
|
|
243
427
|
const held = node.querySelector("[data-agent-chat-held]")
|
|
@@ -252,6 +436,48 @@ export function mountAgentChat(root, {
|
|
|
252
436
|
}
|
|
253
437
|
}
|
|
254
438
|
|
|
439
|
+
// What a turn's text element shows: an agent's words as Markdown, a visitor's
|
|
440
|
+
// as text. `shown` remembers the words behind each element, since an agent's
|
|
441
|
+
// rendered text no longer reads back as its Markdown.
|
|
442
|
+
const shown = new WeakMap()
|
|
443
|
+
function showWords(el, words, agent) {
|
|
444
|
+
shown.set(el, words)
|
|
445
|
+
if (agent) renderMarkdown(el, words, doc)
|
|
446
|
+
else el.textContent = words
|
|
447
|
+
}
|
|
448
|
+
// An agent turn the page rendered itself (studio/agent_chat/_turn prints raw
|
|
449
|
+
// words) has no entry, so it is rendered once as Markdown.
|
|
450
|
+
function shownWords(el, agent) {
|
|
451
|
+
if (shown.has(el)) return shown.get(el)
|
|
452
|
+
return agent ? null : el.textContent
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// TYPE-IN: one answer's words, filled in REVEAL_FRAME_MS steps.
|
|
456
|
+
function startTyping(key, el, words) {
|
|
457
|
+
const started = now()
|
|
458
|
+
const entry = { words, timer: null }
|
|
459
|
+
state.typing.set(key, entry)
|
|
460
|
+
showWords(el, "", true)
|
|
461
|
+
if (log && log.setAttribute) log.setAttribute("aria-busy", "true")
|
|
462
|
+
const step = () => {
|
|
463
|
+
if (!state.alive || state.typing.get(key) !== entry) return
|
|
464
|
+
const { shown: count } = revealPlan(words.length, now() - started)
|
|
465
|
+
showWords(el, words.slice(0, count), true)
|
|
466
|
+
if (state.following) toEnd()
|
|
467
|
+
if (count >= words.length) { stopTyping(key); return }
|
|
468
|
+
entry.timer = setTimer(step, REVEAL_FRAME_MS)
|
|
469
|
+
}
|
|
470
|
+
step()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function stopTyping(key) {
|
|
474
|
+
const entry = state.typing.get(key)
|
|
475
|
+
if (!entry) return
|
|
476
|
+
clearTimer(entry.timer)
|
|
477
|
+
state.typing.delete(key)
|
|
478
|
+
if (!state.typing.size && log && log.removeAttribute) log.removeAttribute("aria-busy")
|
|
479
|
+
}
|
|
480
|
+
|
|
255
481
|
function schedule() {
|
|
256
482
|
if (!state.alive) return
|
|
257
483
|
clearTimer(state.timer)
|
|
@@ -276,6 +502,10 @@ export function mountAgentChat(root, {
|
|
|
276
502
|
state.pending = data.pending === true
|
|
277
503
|
setOffline(false)
|
|
278
504
|
render()
|
|
505
|
+
if (!state.loaded) {
|
|
506
|
+
state.loaded = true
|
|
507
|
+
sendOpener()
|
|
508
|
+
}
|
|
279
509
|
} catch (_error) {
|
|
280
510
|
setOffline(true)
|
|
281
511
|
} finally {
|
|
@@ -315,10 +545,64 @@ export function mountAgentChat(root, {
|
|
|
315
545
|
refresh(event.detail || {})
|
|
316
546
|
}
|
|
317
547
|
|
|
548
|
+
// OPENER: the section's opener, once per key per tab. It goes once the thread
|
|
549
|
+
// has loaded, or at once, AHEAD of the visitor's words, when they send before the
|
|
550
|
+
// thread loads (submit). The tab remembers it as its POST starts (so a chat
|
|
551
|
+
// mounted again mid-flight cannot send it twice) and forgets it if that POST
|
|
552
|
+
// fails; nothing marks it without posting it. While another send is in flight it
|
|
553
|
+
// stays owed, unmarked, and the next send carries it first.
|
|
554
|
+
function sendOpener() {
|
|
555
|
+
if (!opener || state.openerPhase !== "owed") return state.openerPost || undefined
|
|
556
|
+
if (openerKey && storage) {
|
|
557
|
+
try {
|
|
558
|
+
if (storage.getItem(openerKey)) { state.openerPhase = "done"; return undefined }
|
|
559
|
+
} catch (_error) { /* a refused store sends it anyway */ }
|
|
560
|
+
}
|
|
561
|
+
// The thread already ends with these words from the visitor (another tab
|
|
562
|
+
// sent them): showing it is enough.
|
|
563
|
+
const last = state.turns.filter((turn) => turn.role !== "agent").at(-1)
|
|
564
|
+
if (last && String(last.text ?? "").trim() === opener) { state.openerPhase = "done"; return undefined }
|
|
565
|
+
if (state.sending) return undefined
|
|
566
|
+
state.openerPhase = "posting"
|
|
567
|
+
if (openerKey && storage) {
|
|
568
|
+
try { storage.setItem(openerKey, "1") } catch (_error) { /* a refused store sends it anyway */ }
|
|
569
|
+
}
|
|
570
|
+
state.openerPost = post(opener, { typed: false }).finally(() => {
|
|
571
|
+
state.openerPhase = "done"
|
|
572
|
+
state.openerPost = null
|
|
573
|
+
})
|
|
574
|
+
return state.openerPost
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function forgetOpener() {
|
|
578
|
+
if (!openerKey || !storage) return
|
|
579
|
+
try { storage.removeItem(openerKey) } catch (_error) { /* nothing to forget */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// A refused or failed opener goes into the box, after anything already typed,
|
|
583
|
+
// so the visitor can send it themselves.
|
|
584
|
+
function keepWords(text) {
|
|
585
|
+
if (!input) return
|
|
586
|
+
const typed = (input.value || "").trim()
|
|
587
|
+
input.value = typed ? `${typed} ${text}` : text
|
|
588
|
+
}
|
|
589
|
+
|
|
318
590
|
async function submit(event) {
|
|
319
591
|
if (event) event.preventDefault()
|
|
320
592
|
const text = (input.value || "").trim()
|
|
321
|
-
if (!text
|
|
593
|
+
if (!text) return
|
|
594
|
+
// The opener goes first, even before the thread has loaded, and a send
|
|
595
|
+
// waits for an opener already on its way: the visitor's words are never
|
|
596
|
+
// dropped for it, and they always follow it.
|
|
597
|
+
if (state.openerPhase === "owed") await sendOpener()
|
|
598
|
+
if (state.openerPost) await state.openerPost
|
|
599
|
+
if (state.sending) return
|
|
600
|
+
await post(text, { typed: true })
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// One message to the app: the composer's words (typed) or the opener.
|
|
604
|
+
async function post(text, { typed }) {
|
|
605
|
+
if (state.sending) return
|
|
322
606
|
state.sending = true
|
|
323
607
|
if (send) send.disabled = true
|
|
324
608
|
showError("")
|
|
@@ -333,7 +617,7 @@ export function mountAgentChat(root, {
|
|
|
333
617
|
let data = {}
|
|
334
618
|
try { data = await response.json() } catch (_e) { data = {} }
|
|
335
619
|
if (response.status === 201 && data.turn) {
|
|
336
|
-
input.value = ""
|
|
620
|
+
if (typed) input.value = ""
|
|
337
621
|
state.turns = mergeTurns(state.turns, [data.turn])
|
|
338
622
|
state.pending = data.turn.status !== "held"
|
|
339
623
|
state.following = true
|
|
@@ -347,10 +631,12 @@ export function mountAgentChat(root, {
|
|
|
347
631
|
clearTimer(state.timer)
|
|
348
632
|
state.timer = setTimer(poll, POLL_MS)
|
|
349
633
|
} else {
|
|
634
|
+
if (!typed) { forgetOpener(); keepWords(text) }
|
|
350
635
|
showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
|
|
351
636
|
if (status) status.textContent = ""
|
|
352
637
|
}
|
|
353
638
|
} catch (_error) {
|
|
639
|
+
if (!typed) { forgetOpener(); keepWords(text) }
|
|
354
640
|
showError(`Can't reach ${name} right now. Your message is still here; try again in a moment.`)
|
|
355
641
|
if (status) status.textContent = ""
|
|
356
642
|
} finally {
|
|
@@ -391,9 +677,11 @@ export function mountAgentChat(root, {
|
|
|
391
677
|
poll,
|
|
392
678
|
refresh,
|
|
393
679
|
submit,
|
|
680
|
+
sendOpener,
|
|
394
681
|
destroy() {
|
|
395
682
|
state.alive = false
|
|
396
683
|
clearTimer(state.timer)
|
|
684
|
+
for (const key of Array.from(state.typing.keys())) stopTyping(key)
|
|
397
685
|
log.removeEventListener("scroll", onScroll)
|
|
398
686
|
form && form.removeEventListener("submit", submit)
|
|
399
687
|
input && input.removeEventListener("keydown", onKeydown)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// studio/carousel: the arithmetic behind studio/_carousel. The track is a native
|
|
2
|
+
// scroll-snap row, so a swipe, a trackpad and a focused link all move it with no
|
|
3
|
+
// script; studio/controllers/carousel_controller only reads where the row is and
|
|
4
|
+
// moves it for the arrows, the dots and the keyboard.
|
|
5
|
+
//
|
|
6
|
+
// A STOP is a place the row can rest: the start of a slide, until the row can
|
|
7
|
+
// scroll no further, and then its end. Six slides three to a view rest at four
|
|
8
|
+
// stops, so the dots past the last stop are hidden.
|
|
9
|
+
//
|
|
10
|
+
// It imports nothing; test/javascript/carousel.test.mjs loads it as a data: module.
|
|
11
|
+
|
|
12
|
+
// Within this many pixels of an end counts as at it: a zoomed page scrolls in
|
|
13
|
+
// fractions.
|
|
14
|
+
export const TOLERANCE = 2
|
|
15
|
+
|
|
16
|
+
// Which ends of the row hide a slide.
|
|
17
|
+
export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
|
|
18
|
+
const max = Math.max(0, scrollWidth - clientWidth)
|
|
19
|
+
return { start: scrollLeft > TOLERANCE, end: scrollLeft < max - TOLERANCE }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The scroll position of each stop, from each slide's offset and the row's
|
|
23
|
+
// furthest scroll. The slides past the last stop share it.
|
|
24
|
+
export function stops(offsets, max) {
|
|
25
|
+
const reachable = offsets.filter((offset) => offset < max - TOLERANCE)
|
|
26
|
+
if (reachable.length === offsets.length) return reachable.length ? reachable : [0]
|
|
27
|
+
return [...reachable, max]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The stop the row rests at, or is nearest to while it moves.
|
|
31
|
+
export function currentStop(scrollLeft, positions) {
|
|
32
|
+
if (positions.length === 0) return 0
|
|
33
|
+
const last = positions.length - 1
|
|
34
|
+
if (scrollLeft >= positions[last] - TOLERANCE) return last
|
|
35
|
+
|
|
36
|
+
let nearest = 0
|
|
37
|
+
positions.forEach((position, index) => {
|
|
38
|
+
if (Math.abs(position - scrollLeft) < Math.abs(positions[nearest] - scrollLeft)) nearest = index
|
|
39
|
+
})
|
|
40
|
+
return nearest
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// How many whole slides a view shows: an arrow moves the row by that many, so
|
|
44
|
+
// the slide that peeked in at the edge is the first one after the press.
|
|
45
|
+
export function perPage(clientWidth, slideWidth, gap) {
|
|
46
|
+
if (!(slideWidth > 0)) return 1
|
|
47
|
+
return Math.max(1, Math.floor((clientWidth + gap + TOLERANCE) / (slideWidth + gap)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The stop an arrow or a key takes the row to, held to the first and last stop.
|
|
51
|
+
export function targetStop(action, current, page, count) {
|
|
52
|
+
const last = Math.max(0, count - 1)
|
|
53
|
+
const to = {
|
|
54
|
+
prev: current - page,
|
|
55
|
+
next: current + page,
|
|
56
|
+
first: 0,
|
|
57
|
+
last
|
|
58
|
+
}[action]
|
|
59
|
+
if (to === undefined) return current
|
|
60
|
+
return Math.min(last, Math.max(0, to))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Keys inside these belong to the element, not the row: a form field, and a widget
|
|
64
|
+
// that moves its own selection with the arrow keys.
|
|
65
|
+
export const KEY_OWNERS = [
|
|
66
|
+
"input", "textarea", "select", "[contenteditable]:not([contenteditable='false'])",
|
|
67
|
+
"[role='slider']", "[role='spinbutton']", "[role='listbox']", "[role='combobox']",
|
|
68
|
+
"[role='tablist']", "[role='radiogroup']", "[role='menu']", "[role='menubar']",
|
|
69
|
+
"[role='grid']", "[role='tree']", "[role='treegrid']", "video", "audio"
|
|
70
|
+
].join(", ")
|
|
71
|
+
|
|
72
|
+
// Whether a key pressed on `target` is the carousel `region`'s to handle: not
|
|
73
|
+
// when the target sits in a key owner, or in a carousel nested inside this one,
|
|
74
|
+
// which handles its own keys.
|
|
75
|
+
export function ownsKey(target, region, carousel = ".carousel") {
|
|
76
|
+
if (!target?.closest) return false
|
|
77
|
+
if (target.closest(KEY_OWNERS)) return false
|
|
78
|
+
return target.closest(carousel) === region
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The action a key asks for, or null for a key the carousel leaves alone.
|
|
82
|
+
export function keyAction(key) {
|
|
83
|
+
return { ArrowLeft: "prev", ArrowRight: "next", Home: "first", End: "last" }[key] || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The words the status line reads after a move: "Slides 4 to 6 of 9".
|
|
87
|
+
export function positionText(first, shown, total) {
|
|
88
|
+
const from = first + 1
|
|
89
|
+
const to = Math.min(total, first + shown)
|
|
90
|
+
return from === to ? `Slide ${from} of ${total}` : `Slides ${from} to ${to} of ${total}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The spotlight's stops: the scroll position that centres each slide, from each
|
|
94
|
+
// slide's offset and width in the track (its padding included), held to the row.
|
|
95
|
+
// Every slide is a stop, so every dot shows and an arrow moves one slide.
|
|
96
|
+
export function centerStops(slides, clientWidth, max) {
|
|
97
|
+
if (slides.length === 0) return [0]
|
|
98
|
+
return slides.map(({ left, width }) => Math.min(max, Math.max(0, left + width / 2 - clientWidth / 2)))
|
|
99
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// carousel: the arrows, dots and keys of studio/_carousel. The track is a native
|
|
2
|
+
// scroll-snap row, so a swipe, a trackpad and a focused link move it with no
|
|
3
|
+
// script; this controller reads where the row rests and moves it on request.
|
|
4
|
+
//
|
|
5
|
+
// <section class="carousel" data-studio-controller="carousel" ...>
|
|
6
|
+
// <div class="carousel-track" data-carousel-target="track">slides</div>
|
|
7
|
+
// <div class="carousel-controls">dots, prev, next</div>
|
|
8
|
+
// </section>
|
|
9
|
+
//
|
|
10
|
+
// It marks the ends that hide a slide (the edge fade), sets aria-disabled on an
|
|
11
|
+
// arrow at its end, hides the dots past the last stop, and says where the row
|
|
12
|
+
// went in a polite status line, but only after a press: a swipe announces nothing.
|
|
13
|
+
//
|
|
14
|
+
// A lazy controller (studio/stimulus): only a page with a carousel fetches it.
|
|
15
|
+
// Until it connects the controls stay hidden and the row keeps a thin scrollbar,
|
|
16
|
+
// so a carousel whose controller failed is still a row that scrolls.
|
|
17
|
+
//
|
|
18
|
+
// A SPOTLIGHT row (class carousel-spotlight) snaps each slide to the centre: every
|
|
19
|
+
// slide is a stop, an arrow moves one slide, and the slide nearest the centre
|
|
20
|
+
// carries data-current, which the CSS styles where scroll-driven animations are
|
|
21
|
+
// missing and under reduced motion.
|
|
22
|
+
import { centerStops } from "studio/carousel"
|
|
23
|
+
import { Controller } from "@hotwired/stimulus"
|
|
24
|
+
import { currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, stops, targetStop } from "studio/carousel"
|
|
25
|
+
|
|
26
|
+
export default class extends Controller {
|
|
27
|
+
static targets = ["track", "slide", "prev", "next", "dot", "status"]
|
|
28
|
+
|
|
29
|
+
connect() {
|
|
30
|
+
this.schedule = this.schedule.bind(this)
|
|
31
|
+
this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
|
|
32
|
+
if (typeof ResizeObserver === "function") {
|
|
33
|
+
this.observer = new ResizeObserver(this.schedule)
|
|
34
|
+
this.observer.observe(this.trackTarget)
|
|
35
|
+
}
|
|
36
|
+
this.element.setAttribute("data-carousel-ready", "")
|
|
37
|
+
this.sync()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disconnect() {
|
|
41
|
+
this.trackTarget.removeEventListener("scroll", this.schedule)
|
|
42
|
+
this.observer?.disconnect()
|
|
43
|
+
if (this.frame) cancelAnimationFrame(this.frame)
|
|
44
|
+
this.frame = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
schedule() {
|
|
48
|
+
if (this.frame) return
|
|
49
|
+
this.frame = requestAnimationFrame(() => {
|
|
50
|
+
this.frame = null
|
|
51
|
+
this.sync()
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
measure() {
|
|
56
|
+
const track = this.trackTarget
|
|
57
|
+
const slides = this.slideTargets
|
|
58
|
+
if (this.element.classList.contains("carousel-spotlight")) {
|
|
59
|
+
const max = Math.max(0, track.scrollWidth - track.clientWidth)
|
|
60
|
+
const boxes = slides.map((slide) => ({ left: slide.offsetLeft, width: slide.offsetWidth }))
|
|
61
|
+
return { positions: centerStops(boxes, track.clientWidth, max), page: 1 }
|
|
62
|
+
}
|
|
63
|
+
const origin = slides[0]?.offsetLeft || 0
|
|
64
|
+
const max = Math.max(0, track.scrollWidth - track.clientWidth)
|
|
65
|
+
const gap = parseFloat(getComputedStyle(track).columnGap) || 0
|
|
66
|
+
return {
|
|
67
|
+
positions: stops(slides.map((slide) => slide.offsetLeft - origin), max),
|
|
68
|
+
page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
sync() {
|
|
73
|
+
const track = this.trackTarget
|
|
74
|
+
const { positions, page } = this.measure()
|
|
75
|
+
const ends = hiddenEnds(track)
|
|
76
|
+
|
|
77
|
+
this.positions = positions
|
|
78
|
+
this.page = page
|
|
79
|
+
this.current = currentStop(track.scrollLeft, positions)
|
|
80
|
+
|
|
81
|
+
this.element.toggleAttribute("data-carousel-static", positions.length < 2)
|
|
82
|
+
track.toggleAttribute("data-fade-start", ends.start)
|
|
83
|
+
track.toggleAttribute("data-fade-end", ends.end)
|
|
84
|
+
if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!ends.start))
|
|
85
|
+
if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!ends.end))
|
|
86
|
+
if (this.element.classList.contains("carousel-spotlight")) {
|
|
87
|
+
this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
|
|
88
|
+
}
|
|
89
|
+
this.dotTargets.forEach((dot, index) => {
|
|
90
|
+
dot.hidden = index >= positions.length
|
|
91
|
+
if (index === this.current) dot.setAttribute("aria-current", "true")
|
|
92
|
+
else dot.removeAttribute("aria-current")
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
prev() { this.move("prev") }
|
|
97
|
+
|
|
98
|
+
next() { this.move("next") }
|
|
99
|
+
|
|
100
|
+
goTo(event) {
|
|
101
|
+
this.sync()
|
|
102
|
+
this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
key(event) {
|
|
106
|
+
// A form field, a widget that owns the arrow keys, or a nested carousel keeps them.
|
|
107
|
+
if (!ownsKey(event.target, this.element)) return
|
|
108
|
+
// Alt+Left is Back, Cmd+Left a line start: a held modifier leaves the key alone.
|
|
109
|
+
if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return
|
|
110
|
+
const action = keyAction(event.key)
|
|
111
|
+
if (!action) return
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
this.move(action)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A press that goes nowhere (an arrow at its end, Home at the start) scrolls
|
|
117
|
+
// nothing and says nothing: the status line speaks only when the row moves.
|
|
118
|
+
move(action) {
|
|
119
|
+
this.sync()
|
|
120
|
+
const to = targetStop(action, this.current, this.page, this.positions.length)
|
|
121
|
+
if (to === this.current) return
|
|
122
|
+
this.scrollToStop(to)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
scrollToStop(index) {
|
|
126
|
+
const left = this.positions[index]
|
|
127
|
+
if (left === undefined) return
|
|
128
|
+
|
|
129
|
+
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
130
|
+
this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
|
|
131
|
+
|
|
132
|
+
if (!this.hasStatusTarget) return
|
|
133
|
+
const total = this.slideTargets.length
|
|
134
|
+
const last = index === this.positions.length - 1
|
|
135
|
+
const first = last ? Math.max(0, total - this.page) : index
|
|
136
|
+
this.statusTarget.textContent = positionText(first, this.page, total)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// infinite-scroll: the next page of a list loads as its end comes near, on the
|
|
2
|
+
// engine's Stimulus application (studio/stimulus registers it lazily). It sits
|
|
3
|
+
// on studio_infinite_scroll's wrapper. The behaviour is studio/infinite_scroll.
|
|
4
|
+
// If this controller fails to load, "Load more" is still a working link.
|
|
5
|
+
import { Controller } from "@hotwired/stimulus"
|
|
6
|
+
import { watchInfiniteScroll } from "studio/infinite_scroll"
|
|
7
|
+
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
connect() {
|
|
10
|
+
this.stop = watchInfiniteScroll(this.element, window)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
if (this.stop) this.stop()
|
|
15
|
+
this.stop = null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// pull-to-refresh: pull the page down from its top to reload it, on phones with
|
|
2
|
+
// touch only (studio/pull_to_refresh decides). On the engine's Stimulus
|
|
3
|
+
// application (studio/stimulus registers it lazily); it sits on
|
|
4
|
+
// studio_pull_to_refresh's wrapper. If it fails to load, nothing on the page
|
|
5
|
+
// depends on it: the browser's own reload still works.
|
|
6
|
+
import { Controller } from "@hotwired/stimulus"
|
|
7
|
+
import { watchPullToRefresh } from "studio/pull_to_refresh"
|
|
8
|
+
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
connect() {
|
|
11
|
+
this.stop = watchPullToRefresh(this.element, window, document)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
if (this.stop) this.stop()
|
|
16
|
+
this.stop = null
|
|
17
|
+
}
|
|
18
|
+
}
|