studio-engine 0.122.0 → 0.123.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +2 -2
  4. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  5. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  6. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  7. data/app/controllers/studio/voice_takes_controller.rb +11 -0
  8. data/app/controllers/style_controller.rb +2 -5
  9. data/app/controllers/theme_settings_controller.rb +8 -8
  10. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  11. data/app/javascript/studio/agent_chat.js +42 -2
  12. data/app/javascript/studio/agent_chat_voice.js +318 -0
  13. data/app/javascript/studio/alpine_shims.js +1 -3
  14. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  15. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  16. data/app/javascript/studio/microphone_chip.js +1 -0
  17. data/app/javascript/studio/stimulus.js +3 -0
  18. data/app/javascript/studio/style_guide.js +2 -0
  19. data/app/javascript/studio/style_section_nav.js +16 -0
  20. data/app/javascript/studio/theme_editor.js +6 -106
  21. data/app/javascript/studio/voice_recorder.js +23 -2
  22. data/app/models/studio/user_session.rb +76 -0
  23. data/app/models/theme_setting.rb +3 -0
  24. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  25. data/app/views/studio/admin_models/show.html.erb +22 -11
  26. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  27. data/app/views/studio/agent_chat/_form.html.erb +3 -2
  28. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  29. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  30. data/app/views/style/_theme.html.erb +75 -12
  31. data/app/views/style/index.html.erb +28 -30
  32. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  33. data/docs/SITE_FOOTER.md +33 -0
  34. data/lib/studio/agent_chat.rb +83 -5
  35. data/lib/studio/site_footer.rb +45 -2
  36. data/lib/studio/ui_primitives.rb +4 -2
  37. data/lib/studio/version.rb +1 -1
  38. data/lib/studio.rb +64 -6
  39. metadata +11 -4
  40. data/app/views/studio/admin_models/index.html.erb +0 -31
  41. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -16,8 +16,8 @@ module Studio
16
16
  #
17
17
  # THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
18
18
  #
19
- # POST /api/v1/agent_chat/messages soul, text, person, ip
20
- # 201 {turn} · 422 {error} · 429 capped · 404 soul has no web chat
19
+ # POST /api/v1/agent_chat/messages soul, text, person, ip, voice (optional)
20
+ # 201 {turn, voice} · 422 {error} · 429 capped · 404 soul has no web chat
21
21
  # GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
22
22
  # 200 {turns: [...], pending: bool}
23
23
  #
@@ -27,6 +27,11 @@ module Studio
27
27
  # {id, role: "user"|"agent", text, created_at, status} with status
28
28
  # pending|typing|answered|failed.
29
29
  #
30
+ # `voice` marks a spoken message: {check, score, duration_ms}, what POST
31
+ # /voice/takes answered for the take (voice_param). NEVER A NAME: the hub
32
+ # names the speaker itself (the verified person's own name when check is
33
+ # "matched", else "Someone new") and may answer what it kept as `voice`.
34
+ #
30
35
  # AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
31
36
  # else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
32
37
  # plain-http hub URL is refused unless its host is this machine (a developer
@@ -83,6 +88,31 @@ module Studio
83
88
  MAX_TEXT_LENGTH = 4_000
84
89
  # A hub answer larger than this is not a chat answer.
85
90
  MAX_BODY_BYTES = 1_048_576
91
+ # The words beside the microphone, approved by Alex McRitchie on 2026-11-01.
92
+ # Verbatim here and on an app's privacy page, from this one constant.
93
+ VOICE_PRIVACY = "When you speak to an agent, your recording is sent to OpenAI to turn it " \
94
+ "into text, then deleted. We never keep the audio."
95
+
96
+ # The hold-to-talk button's words per recorder state (merged over
97
+ # Studio::VoiceCapture::RECORDER_COPY). The recorder says nothing while it
98
+ # records: the chat's own status line says "Listening...", so the button does
99
+ # not move under a held finger when the line appears.
100
+ VOICE_RECORDER_COPY = {
101
+ "idle" => { "button" => "Hold to talk" },
102
+ "recording" => { "button" => "Let go to send", "status" => "", "heard" => "" },
103
+ "captured" => { "button" => "Hold to talk", "status" => "" },
104
+ "silent" => { "button" => "Hold to talk" },
105
+ "failed" => { "button" => "Hold to try again" },
106
+ "denied" => { "button" => "Hold to talk" },
107
+ "unsupported" => { "button" => "Hold to talk" }
108
+ }.freeze
109
+
110
+ # The microphone chip's first words (the engine's say "tap Record").
111
+ VOICE_CHIP_COPY = {
112
+ "prompt" => { "chip" => "Microphone: hold to talk to allow",
113
+ "note" => "Hold the talk button once and allow the microphone. Then you can pick one here." }
114
+ }.freeze
115
+
86
116
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
87
117
  NETWORK_ERRORS = [
88
118
  Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
@@ -93,13 +123,49 @@ module Studio
93
123
  class << self
94
124
  # Stores one message from `person` to `soul` and answers its turn (a Hash
95
125
  # with string keys). Raises a Studio::AgentChat::Error.
96
- def post_message(soul:, text:, person:, ip: nil)
97
- body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence }.compact
126
+ #
127
+ # `voice` marks it as spoken (voice_param). When the hub answers what it
128
+ # kept, the returned Hash also carries it under "voice".
129
+ def post_message(soul:, text:, person:, ip: nil, voice: nil)
130
+ body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence,
131
+ voice: voice_param(voice) }.compact
98
132
  payload = request(:post, MESSAGES_PATH, body: body)
99
133
  turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
100
134
  raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
101
135
 
102
- turn
136
+ kept = payload["voice"] if payload.is_a?(Hash)
137
+ kept.is_a?(Hash) ? turn.merge("voice" => kept) : turn
138
+ end
139
+
140
+ # The words of the chat's voice option: {"privacy", "recorder", "chip"},
141
+ # the `overrides` (voice_copy: on studio/agent_chat/chat) over the defaults.
142
+ # A blank privacy line is the default one: the line is never just dropped.
143
+ def voice_copy(overrides = nil)
144
+ words = (overrides || {}).to_h.transform_keys(&:to_s)
145
+ {
146
+ "privacy" => words["privacy"].presence || VOICE_PRIVACY,
147
+ "recorder" => Studio::VoiceCapture.merge(VOICE_RECORDER_COPY, words["recorder"]),
148
+ "chip" => Studio::VoiceCapture.merge(VOICE_CHIP_COPY, words["chip"])
149
+ }
150
+ end
151
+
152
+ # What a message's `voice` may say to the hub, as the hub reads it: `check`
153
+ # is one of the engine's own (else Invalid), `score` a cosine (-1..1, 4
154
+ # places) or nil, `duration_ms` 1..MAX_DURATION_MS or nil. Everything else,
155
+ # a browser-chosen name above all, stops here. nil or "" is a typed message.
156
+ def voice_param(voice)
157
+ return nil if voice.nil? || voice == ""
158
+
159
+ voice = voice.to_unsafe_h if voice.respond_to?(:to_unsafe_h)
160
+ raise Invalid.new("voice must be {check, score, duration_ms}", status: 422) unless voice.is_a?(Hash)
161
+
162
+ voice = voice.transform_keys(&:to_s)
163
+ check = voice["check"].to_s
164
+ unless Studio::VoiceTranscription::CHECKS.include?(check)
165
+ raise Invalid.new("voice.check is not one the engine answers", status: 422)
166
+ end
167
+
168
+ { check: check, score: voice_score(voice["score"]), duration_ms: voice_duration(voice["duration_ms"]) }
103
169
  end
104
170
 
105
171
  # The thread's turns after `after` (a turn id; nil for the latest), and
@@ -118,6 +184,18 @@ module Studio
118
184
 
119
185
  private
120
186
 
187
+ def voice_score(raw)
188
+ return nil unless raw.is_a?(Numeric) || raw.is_a?(String)
189
+
190
+ value = Float(raw, exception: false)
191
+ value&.finite? ? value.clamp(-1.0, 1.0).round(4) : nil
192
+ end
193
+
194
+ def voice_duration(raw)
195
+ value = raw.to_s.match?(/\A\d{1,9}\z/) ? raw.to_s.to_i : nil
196
+ value&.between?(1, Studio::VoiceTranscription::MAX_DURATION_MS) ? value : nil
197
+ end
198
+
121
199
  # {email:}, {anon_token:}, or both: a signed-in post that still holds the
122
200
  # visitor's anon token sends both, and the hub folds that anonymous thread
123
201
  # into the person's.
@@ -170,7 +170,8 @@ module Studio
170
170
  # booking_path: the booking page's path (Studio.booking_path_for), or nil
171
171
  # address: Studio.site_footer_address: the address of facts that
172
172
  # carry none of their own
173
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil)
173
+ # chat: the Chat entry's [label, path] (`chat_row`), or nil
174
+ def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: nil)
174
175
  raw = declared.respond_to?(:call) ? declared.call(view) : declared
175
176
  return nil if raw.nil? || raw == false
176
177
 
@@ -189,7 +190,7 @@ module Studio
189
190
  email: text(facts[:email]),
190
191
  address: address_for(facts, address),
191
192
  social: Array(facts[:social]).filter_map { |row| social(row) },
192
- columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
193
+ columns: with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat),
193
194
  legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
194
195
  booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
195
196
  booking_title: text(booking[:title])
@@ -374,6 +375,48 @@ module Studio
374
375
  { heading: heading, links: links, width: column_width(options[:width]) }
375
376
  end
376
377
 
378
+ # The column the standard Chat entry joins, made if the app has none.
379
+ CONTACT_HEADING = "Contact"
380
+
381
+ # The Chat entry for this viewer as a [label, path] row, or nil: no path
382
+ # means the app has no chat, and `allowed` (a callable taking the viewer, nil
383
+ # when signed out) can hide it, e.g. admins only while testing. A hook that
384
+ # raises hides the entry and logs; the footer is on every page, and a link
385
+ # meant for a few must not show because its gate broke.
386
+ def chat_row(view, path:, label: nil, allowed: nil)
387
+ href = path.respond_to?(:call) ? path.call(view) : path
388
+ return nil if text(href).nil?
389
+ return nil unless chat_allowed?(view, allowed)
390
+
391
+ [text(label) || "Chat", href]
392
+ end
393
+
394
+ def chat_allowed?(view, allowed)
395
+ return true if allowed.nil?
396
+
397
+ user = view.respond_to?(:current_user) ? view.current_user : nil
398
+ (allowed.respond_to?(:call) ? allowed.call(user) : allowed) ? true : false
399
+ rescue StandardError => error
400
+ SiteFooter.reporter.call("[studio.site_footer] site_footer_chat_allowed raised #{error.class}: the Chat link is hidden")
401
+ false
402
+ end
403
+
404
+ # `columns` with the Chat entry last in the Contact column (any case), which
405
+ # is added at the end if there is none. A refused href adds nothing, and an
406
+ # app that already links the same path keeps its own row.
407
+ def with_chat(columns, chat)
408
+ row = chat && link(chat)
409
+ return columns if row.nil? || row[:href].nil?
410
+
411
+ at = columns.index { |column| column[:heading].to_s.casecmp?(CONTACT_HEADING) }
412
+ return columns + [{ heading: CONTACT_HEADING, links: [row], width: nil }] if at.nil?
413
+ return columns if columns[at][:links].any? { |known| known[:href] == row[:href] }
414
+
415
+ columns.each_with_index.map do |column, index|
416
+ index == at ? column.merge(links: column[:links] + [row]) : column
417
+ end
418
+ end
419
+
377
420
  # A column's width hint, or nil for the default share. Anything that is not a
378
421
  # number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
379
422
  MIN_COLUMN_WIDTH = 0.5
@@ -10,8 +10,10 @@ module Studio
10
10
  align-items: center;
11
11
  justify-content: center;
12
12
  vertical-align: middle;
13
- width: 1.5rem;
14
- height: 1.25rem;
13
+ /* In em so a larger glyph fits: at text-lg on a 16px scale a fixed 1.25rem
14
+ box clipped 4px off a 24px emoji. The floor keeps the navbar size. */
15
+ width: max(1.5rem, 1.25em);
16
+ height: max(1.25rem, 1.25em);
15
17
  overflow: hidden;
16
18
  }
17
19
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.122.0"
2
+ VERSION = "0.123.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1471,7 +1471,7 @@ module Studio
1471
1471
  mattr_accessor :agent_chat_souls, default: []
1472
1472
  mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
1473
1473
  # The app's bearer credential for the hub. Leave nil to read
1474
- # ENV["AGENT_CHAT_TOKEN"] (1Password apps.<slug>.agent-chat).
1474
+ # ENV["AGENT_CHAT_TOKEN"] (1Password agent-chat.<slug>.applications).
1475
1475
  mattr_accessor :agent_chat_token, default: nil
1476
1476
  # Which signed-in users the hub may know by email. An email the user only
1477
1477
  # CLAIMS (the profile page saves a new address and clears email_verified_at;
@@ -1486,6 +1486,50 @@ module Studio
1486
1486
  Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
1487
1487
  end
1488
1488
 
1489
+ # Who may use the chat at all: a callable taking the user, nil when signed out.
1490
+ # The server's gate, not only the look: Studio::AgentChatsController answers a
1491
+ # refused viewer 403 on both calls before any hub call, and the chat partial
1492
+ # renders nothing for them. The footer's Chat link follows it too unless
1493
+ # site_footer_chat_allowed is set, so one setting covers both. Default: everyone
1494
+ # (a public chat). While testing:
1495
+ #
1496
+ # config.agent_chat_allowed = ->(user) { user&.admin? }
1497
+ mattr_accessor :agent_chat_allowed, default: ->(_user) { true }
1498
+
1499
+ # The one reading of Studio.agent_chat_allowed. A hook that raises answers false
1500
+ # and logs: `user.admin?` on a signed-out visitor closes the chat, never opens it.
1501
+ def self.agent_chat_allowed?(user)
1502
+ hook = agent_chat_allowed
1503
+ (hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
1504
+ rescue StandardError => e
1505
+ Rails.logger&.warn("[studio] agent_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
1506
+ false
1507
+ end
1508
+
1509
+ # Who may talk by voice (docs/AGENT_CHAT.md, "Talking by voice"): a callable
1510
+ # taking the signed-in user. It decides who sees the chat's microphone, who
1511
+ # may post a take to POST /voice/takes (each one is a paid vendor call), and
1512
+ # whether the proxy forwards a message's `voice`. A signed-out visitor is
1513
+ # never asked about: no user, no voice. While testing, an app narrows it:
1514
+ #
1515
+ # config.voice_chat_allowed = ->(user) { user.admin? }
1516
+ #
1517
+ # A user Studio.agent_chat_allowed refuses may not talk either, whatever this says.
1518
+ mattr_accessor :voice_chat_allowed, default: ->(user) { user.present? }
1519
+
1520
+ # The one reading of Studio.voice_chat_allowed. False for no user, and for a user
1521
+ # the chat itself refuses, whatever the hook says. A hook that raises answers false and logs: the safe answer on a
1522
+ # paid, sign-in-only control, and a page never falls over because of it.
1523
+ def self.voice_chat_allowed?(user)
1524
+ return false if user.nil? || !agent_chat_allowed?(user)
1525
+
1526
+ hook = voice_chat_allowed
1527
+ (hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
1528
+ rescue StandardError => e
1529
+ Rails.logger&.warn("[studio] voice_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
1530
+ false
1531
+ end
1532
+
1489
1533
  class S3ConfigError < StandardError; end
1490
1534
 
1491
1535
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -1733,6 +1777,20 @@ module Studio
1733
1777
  SidebarSections.resolve(sidebar_sections, view)
1734
1778
  end
1735
1779
 
1780
+ # The standard Chat entry in the footer's Contact column (docs/SITE_FOOTER.md,
1781
+ # "The Chat link"). OFF until the app names a path: no path, no entry.
1782
+ #
1783
+ # config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
1784
+ # config.site_footer_chat_label = "Chat" # the default
1785
+ # config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
1786
+ #
1787
+ # site_footer_chat_allowed takes the viewer, nil when signed out; a hook that
1788
+ # raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
1789
+ # Studio.agent_chat_allowed, so the link shows to whom the chat answers.
1790
+ mattr_accessor :site_footer_chat_path, default: nil
1791
+ mattr_accessor :site_footer_chat_label, default: "Chat"
1792
+ mattr_accessor :site_footer_chat_allowed, default: nil
1793
+
1736
1794
  # The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
1737
1795
  # nil when the app turned the footer off (or its own callable answered nil).
1738
1796
  # Unset, the facts are the default footer's. The name defaults to the site
@@ -1744,7 +1802,10 @@ module Studio
1744
1802
 
1745
1803
  declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
1746
1804
  SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1747
- booking_path: booking_path_for(view), address: site_footer_address)
1805
+ booking_path: booking_path_for(view), address: site_footer_address,
1806
+ chat: SiteFooter.chat_row(view, path: site_footer_chat_path,
1807
+ label: site_footer_chat_label,
1808
+ allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed))
1748
1809
  end
1749
1810
 
1750
1811
  # The host's route set, for the default footer's legal links. nil outside a
@@ -2051,11 +2112,8 @@ module Studio
2051
2112
  get "schedule", to: "studio/bookings#show", as: :studio_booking
2052
2113
  end
2053
2114
 
2054
- # The living style guide. Canonical at /admin/style (StyleController#index);
2055
- # /admin/design_system redirects here but KEEPS its admin_design_system_path
2056
- # helper so a shipped host sidebar link on the old helper still resolves.
2115
+ # The living style guide (StyleController#index).
2057
2116
  get "admin/style", to: "style#index", as: :admin_style
2058
- get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
2059
2117
 
2060
2118
  # The component gallery: Lookbook over the engine's ViewComponent previews.
2061
2119
  # The router refuses anyone but a signed-in admin, so a visitor and a
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.122.0
4
+ version: 0.123.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -477,6 +477,7 @@ files:
477
477
  - app/controllers/style_controller.rb
478
478
  - app/controllers/theme_settings_controller.rb
479
479
  - app/helpers/studio/admin_models_table_helper.rb
480
+ - app/helpers/studio/agent_chat_helper.rb
480
481
  - app/helpers/studio/at_time_helper.rb
481
482
  - app/helpers/studio/fizz_helper.rb
482
483
  - app/helpers/studio/geo_helper.rb
@@ -489,6 +490,7 @@ files:
489
490
  - app/helpers/studio_sidebar_helper.rb
490
491
  - app/helpers/studio_theme_helper.rb
491
492
  - app/javascript/studio/agent_chat.js
493
+ - app/javascript/studio/agent_chat_voice.js
492
494
  - app/javascript/studio/alpine_scopes.js
493
495
  - app/javascript/studio/alpine_shims.js
494
496
  - app/javascript/studio/alpine_stores.js
@@ -498,6 +500,7 @@ files:
498
500
  - app/javascript/studio/booking.js
499
501
  - app/javascript/studio/confirm_interstitial.js
500
502
  - app/javascript/studio/controllers/agent_chat_controller.js
503
+ - app/javascript/studio/controllers/agent_chat_voice_controller.js
501
504
  - app/javascript/studio/controllers/board_controller.js
502
505
  - app/javascript/studio/controllers/booking_controller.js
503
506
  - app/javascript/studio/controllers/email_banner_scale_controller.js
@@ -512,6 +515,7 @@ files:
512
515
  - app/javascript/studio/controllers/modal_host_controller.js
513
516
  - app/javascript/studio/controllers/nav_collapse_controller.js
514
517
  - app/javascript/studio/controllers/style_modals_controller.js
518
+ - app/javascript/studio/controllers/style_section_nav_controller.js
515
519
  - app/javascript/studio/controllers/survey_controller.js
516
520
  - app/javascript/studio/controllers/toast_controller.js
517
521
  - app/javascript/studio/controllers/voice_profile_controller.js
@@ -548,6 +552,7 @@ files:
548
552
  - app/javascript/studio/studio_confetti.js
549
553
  - app/javascript/studio/style_guide.js
550
554
  - app/javascript/studio/style_modals.js
555
+ - app/javascript/studio/style_section_nav.js
551
556
  - app/javascript/studio/survey.js
552
557
  - app/javascript/studio/theme_editor.js
553
558
  - app/javascript/studio/toast.js
@@ -581,6 +586,7 @@ files:
581
586
  - app/models/studio/model_page.rb
582
587
  - app/models/studio/site_identity.rb
583
588
  - app/models/studio/survey_response.rb
589
+ - app/models/studio/user_session.rb
584
590
  - app/models/theme_setting.rb
585
591
  - app/services/google_oauth_validator.rb
586
592
  - app/services/studio/banner.rb
@@ -631,8 +637,8 @@ files:
631
637
  - app/views/studio/_leveling_activity_assets.html.erb
632
638
  - app/views/studio/_session_stamp.html.erb
633
639
  - app/views/studio/admin_models/_arenas_table.html.erb
640
+ - app/views/studio/admin_models/_sessions_table.html.erb
634
641
  - app/views/studio/admin_models/_teams_table.html.erb
635
- - app/views/studio/admin_models/index.html.erb
636
642
  - app/views/studio/admin_models/show.html.erb
637
643
  - app/views/studio/admin_surveys/index.html.erb
638
644
  - app/views/studio/admin_surveys/show.html.erb
@@ -641,6 +647,7 @@ files:
641
647
  - app/views/studio/agent_chat/_header.html.erb
642
648
  - app/views/studio/agent_chat/_turn.html.erb
643
649
  - app/views/studio/agent_chat/_typing.html.erb
650
+ - app/views/studio/agent_chat/_voice.html.erb
644
651
  - app/views/studio/banners/_app_banner.html.erb
645
652
  - app/views/studio/banners/_button.html.erb
646
653
  - app/views/studio/banners/_dev_mode_button.html.erb
@@ -796,7 +803,6 @@ files:
796
803
  - app/views/style/modals/_unsubscribe_confirm.html.erb
797
804
  - app/views/style/modals/_wallet_connect.html.erb
798
805
  - app/views/style/modals/_wallet_connect_slot.html.erb
799
- - app/views/theme_settings/edit.html.erb
800
806
  - app/views/user_mailer/magic_link.html.erb
801
807
  - app/views/user_mailer/magic_link.text.erb
802
808
  - config/importmap.rb
@@ -821,6 +827,7 @@ files:
821
827
  - db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
822
828
  - db/migrate/20261010120000_add_stored_in_to_image_caches.rb
823
829
  - db/migrate/20261029120000_add_voice_profile_to_users.rb
830
+ - db/migrate/20261103120000_create_studio_user_sessions.rb
824
831
  - docs/BOOKING.md
825
832
  - docs/SITE_FOOTER.md
826
833
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -1,31 +0,0 @@
1
- <% content_for(:title, "Models - Admin") %>
2
-
3
- <div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
4
- <header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
5
- <div>
6
- <p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin QA</p>
7
- <h1 class="text-heading text-2xl font-bold mt-1">Models</h1>
8
- <p class="text-muted text-sm mt-1">Ten-row snapshots for records that need quick inspection.</p>
9
- </div>
10
- <%= link_to "Back to dashboard", admin_dashboard_path, class: "btn btn-outline" %>
11
- </header>
12
-
13
- <div class="space-y-5">
14
- <% @sections.each do |section| %>
15
- <section id="model-<%= section.fetch(:key) %>" class="card overflow-hidden">
16
- <div class="px-4 py-4 border-b border-subtle flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
17
- <div>
18
- <h2 class="text-heading text-lg font-semibold"><%= section.fetch(:label) %></h2>
19
- <p class="text-xs text-muted"><%= section.fetch(:description) %></p>
20
- </div>
21
- <div class="flex items-center gap-3">
22
- <span class="text-xs text-muted">Showing <%= section.fetch(:records).size %> of <%= section.fetch(:count) %></span>
23
- <%= link_to "View all", admin_model_path(section.fetch(:key)), class: "text-xs text-primary hover:underline" %>
24
- </div>
25
- </div>
26
-
27
- <%= render admin_models_table_partial(section.fetch(:key)), records: section.fetch(:records) %>
28
- </section>
29
- <% end %>
30
- </div>
31
- </div>