studio-engine 0.115.4.rc1 → 0.116.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa2d9adfea1b7487c4d3e6179f6c9f35883f66220d453289486c70751491ba46
4
- data.tar.gz: 1d37a2a532562cfc6ade149f582f902638b72c4d7f094663297178ca54c4b605
3
+ metadata.gz: 2c4110fb2193a6a381c74f20802d33e407c06192bd454f5b3e11b8f681cb9728
4
+ data.tar.gz: acc4906d77b7a8e286c5c601c86113964e3b7fab1073fe1332b77f0e54e78a61
5
5
  SHA512:
6
- metadata.gz: 36e42c01b2ccdbf1d014d618148d4243716e55f15ab5bbfa126f425d26b60c8920ad4804cdc485394add55c580398c76d98fb208a8e546cf8737255d7ab7f9ea
7
- data.tar.gz: 53fcc424881f1aec06cb246b5c458a48f98f4806d1723c2f92f23c748e9d18c448902fa7f808b3d526fb28e35da669fb669cb4bc31127417900c581a15446288
6
+ metadata.gz: 5bb8eb6f9ef719a032a0a1ec05394222a70753e107a406fb82c7c2adf0cea769fc673395448e65fe3d356542450edb933f9eb9e8402b9a7dc14df86c5991d47d
7
+ data.tar.gz: 76a7b9f0d7a60022406cdfa66b0b02e5245ef7bba95cbdcc997c6c9277b00c103cb85547fb44ce89996844c3f39c9b412736321b32bffa0eb34b923b6e0a3fcf
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.116.0 — 2026-10-28
8
+
9
+ ### Added
10
+
11
+ - **The shared agent chat.** `render "studio/agent_chat/chat", soul:, name:, portrait:`
12
+ puts one chat with a hub agent on any page: the portrait header, a card slot
13
+ (`cards:`), the thread, the thinking/typing line, the composer and a friendly
14
+ retry when the hub is down, lifted from the hub's /build chat. The hub holds the
15
+ thread; `Studio::AgentChatsController` (`POST /agent_chat/messages`,
16
+ `GET /agent_chat/turns`) proxies to it with the signed-in user's email or a
17
+ signed anonymous cookie token, and `Studio::AgentChat` is the bounded hub
18
+ client. Only a verified email (`email_verified_at`, or the app's
19
+ `Studio.agent_chat_verified_email` hook) names a person to the hub; anyone
20
+ else chats anonymously. Opt in with `Studio.agent_chat_souls` and
21
+ `AGENT_CHAT_TOKEN`; no soul, no route. [`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md).
22
+
23
+ ### Changed
24
+
25
+ - **The gear, the name and the avatar light together.** Where the account opens
26
+ the link sidebar, as the gear does, pointing at or focusing any of the three
27
+ turns the gear and the name the theme's primary, turns the gear an eighth
28
+ (not under reduced motion) and puts a soft primary glow on the avatar; the
29
+ gear's own hover pill is dropped inside the group. CSS only (`engine.css`,
30
+ *THE ACCOUNT GROUP*), keyed on the row's existing hooks, so the navbar
31
+ baseline's bytes are unchanged. Two shapes group: the engine's link sidebar
32
+ gear with an account that opens it, and a host's gear in the menu trigger
33
+ seat (`Studio.navbar_menu_trigger`) with an account that opens the host's
34
+ sidebar (`Studio.navbar_account_sidebar`), as Turf Monster's gear sidebar
35
+ does. An account link is never grouped. Guards: `test/views/navbar_account_group_css_test.rb`,
36
+ `test/views/user_nav_test.rb`, `e2e/navbar_account_group.spec.js`.
37
+
7
38
  ## 0.115.4 — 2026-10-27
8
39
 
9
40
  ### Changed
data/README.md CHANGED
@@ -245,6 +245,8 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
245
245
 
246
246
  **Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
247
247
 
248
+ Set `Studio.agent_chat_souls = %w[alex]` to add the shared agent chat's proxy (`POST /agent_chat/messages`, `GET /agent_chat/turns`), which forwards to the McRitchie Studio hub with the app's `AGENT_CHAT_TOKEN`; then `render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: ...` on any page ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)).
249
+
248
250
  In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
249
251
 
250
252
  In every environment, production included, it also draws `/_studio/health_report`: the daily health report as counts-only JSON, which the McRitchie Studio hub pulls at 07:00 and posts to Discord. It answers 404 until the app sets `STUDIO_HEALTH_TOKEN` (or `config.health_report_token`), and 401 to a request without that bearer token ([`docs/HEALTH_REPORT.md`](docs/HEALTH_REPORT.md#the-hub-endpoint)).
@@ -1097,6 +1099,13 @@ helper `studio_account_opens_sidebar?` to decide otherwise; with no sidebar
1097
1099
  the account stays a link to the account page. Log out lives in the sidebar,
1098
1100
  never the bar.
1099
1101
 
1102
+ Since the gear and the account open the same sidebar, they read as one control:
1103
+ pointing at or focusing any of the gear, the name or the avatar lights all three
1104
+ (the primary colour, a turn of the gear, a primary glow on the avatar). The
1105
+ same holds for a host sidebar: a host that sets both `navbar_menu_trigger` and
1106
+ `navbar_account_sidebar` gets its gear and account lit as one menu. An account
1107
+ link is never grouped (`engine.css`, *THE ACCOUNT GROUP*).
1108
+
1100
1109
  ## Transactional emails
1101
1110
 
1102
1111
  Every consuming app can render the same admin page — **`/admin/emails`** —
@@ -2010,3 +2010,156 @@ body.modal-open { overflow: visible; }
2010
2010
  .email-reject { animation-name: email-reject-fade; }
2011
2011
  }
2012
2012
  }
2013
+
2014
+ /* -- Agent chat: a pending answer's indicator --------------------------------
2015
+ studio/agent_chat/_typing, lifted from the hub's agent_typing.css. Two looks
2016
+ share one grid cell, so the swap from thinking to typing never moves the page:
2017
+
2018
+ THINKING the agent's portrait in a soft turning ring with a glow, and
2019
+ "<Agent> is thinking" with a slow shimmer across it.
2020
+ TYPING a chat bubble on the agent's side with three wave dots.
2021
+
2022
+ data-phase picks the look (studio/agent_chat sets it); the other fades out
2023
+ and is hidden, so it is not read aloud. Theme tokens only, so dark mode
2024
+ follows the theme. Under prefers-reduced-motion nothing moves and the swap
2025
+ is instant. In the components layer, like every rule here; needs this
2026
+ layer, which host.css imports (a host that skips it sees both labels). */
2027
+ @layer components {
2028
+ .studio-agent-typing {
2029
+ --studio-agent-typing-beat: 2.4s;
2030
+ --studio-agent-typing-fade: 280ms;
2031
+ display: grid;
2032
+ align-items: center;
2033
+ padding-block: 0.375rem;
2034
+ font-size: 0.75rem;
2035
+ line-height: 1rem;
2036
+ color: var(--color-text-muted);
2037
+ }
2038
+
2039
+ .studio-agent-typing > .studio-agent-typing-layer {
2040
+ grid-area: 1 / 1;
2041
+ display: flex;
2042
+ align-items: center;
2043
+ gap: 0.625rem;
2044
+ min-width: 0;
2045
+ transition: opacity var(--studio-agent-typing-fade) ease, transform var(--studio-agent-typing-fade) ease,
2046
+ visibility 0s linear 0s;
2047
+ }
2048
+
2049
+ .studio-agent-typing[data-phase="thinking"] > .studio-agent-typing-types,
2050
+ .studio-agent-typing[data-phase="typing"] > .studio-agent-typing-thinks {
2051
+ opacity: 0;
2052
+ transform: translateY(3px);
2053
+ visibility: hidden;
2054
+ pointer-events: none;
2055
+ transition: opacity var(--studio-agent-typing-fade) ease, transform var(--studio-agent-typing-fade) ease,
2056
+ visibility 0s linear var(--studio-agent-typing-fade);
2057
+ }
2058
+
2059
+ .studio-agent-typing-label {
2060
+ min-width: 0;
2061
+ overflow: hidden;
2062
+ text-overflow: ellipsis;
2063
+ white-space: nowrap;
2064
+ }
2065
+
2066
+ .studio-agent-typing-orbit {
2067
+ position: relative;
2068
+ display: inline-flex;
2069
+ flex: none;
2070
+ border-radius: 9999px;
2071
+ padding: 3px;
2072
+ }
2073
+
2074
+ .studio-agent-typing-orbit::before,
2075
+ .studio-agent-typing-orbit::after {
2076
+ content: "";
2077
+ position: absolute;
2078
+ inset: 0;
2079
+ border-radius: inherit;
2080
+ pointer-events: none;
2081
+ }
2082
+
2083
+ .studio-agent-typing-orbit::before {
2084
+ background: conic-gradient(
2085
+ from 0deg,
2086
+ rgb(var(--color-primary-rgb) / 0) 0deg,
2087
+ rgb(var(--color-primary-rgb) / 0.25) 90deg,
2088
+ rgb(var(--color-primary-rgb) / 0.95) 200deg,
2089
+ rgb(var(--color-primary-rgb) / 0) 300deg
2090
+ );
2091
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
2092
+ mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
2093
+ animation: studio-agent-typing-spin var(--studio-agent-typing-beat) linear infinite;
2094
+ }
2095
+
2096
+ .studio-agent-typing-orbit::after {
2097
+ box-shadow: 0 0 0.9rem 0.05rem rgb(var(--color-primary-rgb) / 0.35);
2098
+ animation: studio-agent-typing-breathe var(--studio-agent-typing-beat) ease-in-out infinite;
2099
+ }
2100
+
2101
+ .studio-agent-typing-orbit > * {
2102
+ position: relative;
2103
+ z-index: 1;
2104
+ }
2105
+
2106
+ .studio-agent-typing-shimmer {
2107
+ background-image: linear-gradient(100deg, var(--color-text-muted) 0%, var(--color-text-muted) 38%,
2108
+ var(--color-text) 50%, var(--color-text-muted) 62%, var(--color-text-muted) 100%);
2109
+ background-size: 250% 100%;
2110
+ -webkit-background-clip: text;
2111
+ background-clip: text;
2112
+ -webkit-text-fill-color: transparent;
2113
+ color: transparent;
2114
+ animation: studio-agent-typing-shimmer var(--studio-agent-typing-beat) ease-in-out infinite;
2115
+ }
2116
+
2117
+ .studio-agent-typing-bubble {
2118
+ display: inline-flex;
2119
+ align-items: center;
2120
+ gap: 0.25rem;
2121
+ flex: none;
2122
+ padding: 0.625rem 0.75rem;
2123
+ border-radius: 1rem 1rem 1rem 0.25rem;
2124
+ background: var(--color-inset);
2125
+ border: 1px solid var(--color-border);
2126
+ }
2127
+
2128
+ .studio-agent-typing-dot {
2129
+ width: 0.4rem;
2130
+ height: 0.4rem;
2131
+ border-radius: 9999px;
2132
+ background: var(--color-text-muted);
2133
+ animation: studio-agent-typing-wave calc(var(--studio-agent-typing-beat) / 2) ease-in-out infinite;
2134
+ }
2135
+
2136
+ .studio-agent-typing-dot:nth-child(2) { animation-delay: 0.15s; }
2137
+ .studio-agent-typing-dot:nth-child(3) { animation-delay: 0.3s; }
2138
+
2139
+ @media (prefers-reduced-motion: reduce) {
2140
+ .studio-agent-typing-orbit::before,
2141
+ .studio-agent-typing-orbit::after,
2142
+ .studio-agent-typing-shimmer,
2143
+ .studio-agent-typing-dot { animation: none; }
2144
+ .studio-agent-typing > .studio-agent-typing-layer { transition: none; }
2145
+ }
2146
+
2147
+ @keyframes studio-agent-typing-spin {
2148
+ to { transform: rotate(360deg); }
2149
+ }
2150
+
2151
+ @keyframes studio-agent-typing-breathe {
2152
+ 0%, 100% { opacity: 0.45; }
2153
+ 50% { opacity: 1; }
2154
+ }
2155
+
2156
+ @keyframes studio-agent-typing-shimmer {
2157
+ 0% { background-position: 100% 0; }
2158
+ 100% { background-position: 0% 0; }
2159
+ }
2160
+
2161
+ @keyframes studio-agent-typing-wave {
2162
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
2163
+ 30% { transform: translateY(-3px); opacity: 1; }
2164
+ }
2165
+ }
@@ -723,9 +723,55 @@ body.modal-open .studio-app-banner {
723
723
  top: -0.25rem;
724
724
  pointer-events: auto;
725
725
  }
726
+ /* THE ACCOUNT GROUP (components/_user_nav). Where the gear and the account
727
+ (name and avatar) open the same sidebar, the three are one control to
728
+ the eye: pointing at or focusing any of them lights all three. The gear
729
+ and the name take the primary colour, the gear turns an eighth, and the
730
+ avatar glows in the brand's primary. The gear drops its own hover pill,
731
+ which would mark it out as a separate control.
732
+
733
+ The group is the user nav's row, found by what it holds, so no markup
734
+ changes (the navbar baseline holds its bytes). Two shapes group:
735
+ THE LINK SIDEBAR: the engine's gear ([data-link-sidebar-trigger]) and
736
+ an account whose aria-controls names studio-link-sidebar.
737
+ A HOST SIDEBAR: the host's gear in the menu trigger seat
738
+ (Studio.navbar_menu_trigger) and an account that opens the host's
739
+ sidebar (Studio.navbar_account_sidebar). A host that sets both is
740
+ saying they are its one menu, as Turf Monster's gear sidebar is.
741
+ An account link is never grouped. */
742
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger] svg,
743
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-nav-account] > [aria-hidden] > * {
744
+ transition: transform 200ms ease, box-shadow 200ms ease;
745
+ }
746
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
747
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-link-sidebar-trigger],
748
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-nav-name] { color: var(--color-primary); }
749
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-nav-account] > [aria-hidden] > * {
750
+ box-shadow:
751
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
752
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
753
+ }
754
+ @media (prefers-reduced-motion: no-preference) {
755
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
756
+ }
757
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls] svg,
758
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-account] > [aria-hidden] > * {
759
+ transition: transform 200ms ease, box-shadow 200ms ease;
760
+ }
761
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
762
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-menu-trigger] [aria-controls],
763
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-name] { color: var(--color-primary); }
764
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-account] > [aria-hidden] > * {
765
+ box-shadow:
766
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
767
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
768
+ }
769
+ @media (prefers-reduced-motion: no-preference) {
770
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
771
+ }
726
772
  /* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
727
- navbar renders the engine's user nav gets this; Turf Monster forks the
728
- partial, carries none of these hooks, and keeps its own two-row nav.
773
+ navbar renders the engine's user nav gets this, Turf Monster included:
774
+ it renders the engine's partial, its gear in the menu trigger seat.
729
775
 
730
776
  ICONS. The theme toggle leads the rail (the partial renders it first) and
731
777
  both icons sit a size down: the moon at 14px, the gear's 16px glyph in a
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The shared agent chat's proxy (docs/AGENT_CHAT.md). The browser talks only
5
+ # to its own app; this controller forwards its two calls to the hub through
6
+ # Studio::AgentChat:
7
+ #
8
+ # POST /agent_chat/messages soul, text -> 201 {turn}
9
+ # GET /agent_chat/turns soul, after, limit -> 200 {turns, pending}
10
+ #
11
+ # Drawn by Studio.routes only for an app that names a soul
12
+ # (Studio.agent_chat_souls).
13
+ #
14
+ # WHAT THE BROWSER MAY CHOOSE, AND WHAT IT MAY NOT:
15
+ #
16
+ # soul only from the app's allow-list (Studio.agent_chat_souls); off the
17
+ # list, or absent when the list holds more than one, is a 404. The
18
+ # hub re-checks that this app may address it.
19
+ # person never. A signed-in user with a VERIFIED email (email_verified_at
20
+ # present, or the app's Studio.agent_chat_verified_email hook) is
21
+ # that email. Everyone else, signed out or signed in with an address
22
+ # they only claimed (the profile page and password signup save any
23
+ # address), is a random token in a SIGNED, http-only cookie
24
+ # (ANON_COOKIE), minted on the first message: a claimed address
25
+ # would open its owner's hub thread on every app. A browser cannot
26
+ # name another person's email or forge another's token. A verified
27
+ # POST that still holds the cookie sends both, so the hub folds the
28
+ # anonymous thread into the person's; the cookie is then deleted.
29
+ # ip never: request.remote_ip, which the hub caps on.
30
+ #
31
+ # The host's ApplicationController is the base, so its session, current_user
32
+ # and CSRF protection hold: the POST must carry the page's authenticity token
33
+ # (the composer's form does; the script sends it as X-CSRF-Token). Sign-in is
34
+ # skipped: a signed-out visitor may chat, as on /build.
35
+ #
36
+ # Every answer is JSON and no-store. A hub that is down, slow or misconfigured
37
+ # is a 503 {error, retry: true}, which the page shows as a friendly retry, and
38
+ # a failed send or a refused app credential is an ErrorLog row (#capture).
39
+ class AgentChatsController < ::ApplicationController
40
+ skip_before_action :require_authentication, raise: false
41
+ protect_from_forgery with: :exception
42
+
43
+ ANON_COOKIE = :studio_agent_chat_anon
44
+ ANON_TTL = 1.year
45
+ UNAVAILABLE = "Can't reach the chat right now. Try again in a moment."
46
+
47
+ before_action :no_store
48
+ before_action :resolve_soul
49
+
50
+ def create
51
+ text = params[:text].to_s.strip
52
+ return render_error("Write a message first.", :unprocessable_entity) if text.empty?
53
+ if text.length > Studio::AgentChat::MAX_TEXT_LENGTH
54
+ return render_error("That message is too long (#{Studio::AgentChat::MAX_TEXT_LENGTH} characters at most).",
55
+ :unprocessable_entity)
56
+ end
57
+
58
+ who = person(mint: true, fold: true)
59
+ turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip)
60
+ # A signed-in post that carried the anon token has folded that thread into
61
+ # the person's: the token is spent. Kept on any failure, for the retry.
62
+ cookies.delete(ANON_COOKIE) if who.key?(:email) && who.key?(:anon_token)
63
+ render json: { turn: turn }, status: :created
64
+ rescue Studio::AgentChat::Error => e
65
+ render_hub_error(e)
66
+ end
67
+
68
+ def turns
69
+ who = person(mint: false)
70
+ # A visitor who has never sent a message has no thread; the hub need not be asked.
71
+ return render(json: { turns: [], pending: false }) unless who
72
+
73
+ after = params[:after].to_s.presence
74
+ after = nil if after && !after.match?(/\A[\w-]{1,64}\z/)
75
+ limit = params[:limit].presence&.to_i || Studio::AgentChat::MAX_LIMIT
76
+ render json: Studio::AgentChat.turns(soul: @soul, person: who, after: after, limit: limit)
77
+ rescue Studio::AgentChat::Error => e
78
+ render_hub_error(e)
79
+ end
80
+
81
+ private
82
+
83
+ def no_store
84
+ response.headers["Cache-Control"] = "no-store"
85
+ end
86
+
87
+ def resolve_soul
88
+ souls = Studio.agent_chat_soul_list
89
+ asked = params[:soul].to_s.strip
90
+ @soul = if asked.empty? then (souls.one? ? souls.first : nil)
91
+ else souls.find { |soul| soul == asked }
92
+ end
93
+ render_error("No chat here.", :not_found) unless @soul
94
+ end
95
+
96
+ # {email:} for a user with a VERIFIED email (#verified_email), else
97
+ # {anon_token:} from the signed cookie, as for a signed-out visitor. With
98
+ # mint: false a visitor with no cookie is nil.
99
+ #
100
+ # THE FOLD. With fold: true (the POST) a verified user who still holds a
101
+ # valid anon cookie is {email:, anon_token:}: the hub folds the anonymous
102
+ # thread into the person's, and #create then deletes the cookie. A verified
103
+ # user is never minted a token, and GET /turns sends the email alone (the
104
+ # fold is the hub's job on a post).
105
+ def person(mint:, fold: false)
106
+ email = verified_email
107
+ token = anon_token
108
+ if email
109
+ return fold && token ? { email: email, anon_token: token } : { email: email }
110
+ end
111
+ return { anon_token: token } if token
112
+ return nil unless mint
113
+
114
+ token = SecureRandom.urlsafe_base64(32)
115
+ cookies.signed[ANON_COOKIE] = {
116
+ value: token, expires: ANON_TTL.from_now, httponly: true, same_site: :lax,
117
+ secure: request.ssl?
118
+ }
119
+ { anon_token: token }
120
+ end
121
+
122
+ # The email the hub may know this user by, or nil. The hub keys a person's
123
+ # threads on it across every app, so a CLAIMED address is never enough: the
124
+ # profile page saves a new email directly and clears email_verified_at, and
125
+ # password signup saves any address. The app's Studio.agent_chat_verified_email
126
+ # hook decides when set; otherwise only a user whose email_verified_at is
127
+ # present (the magic link stamps it; so does the host from_omniauth pattern
128
+ # in docs/GOOGLE_AUTH_SETUP.md) counts. Anyone else chats as an
129
+ # anonymous visitor.
130
+ def verified_email
131
+ user = respond_to?(:current_user, true) ? current_user : nil
132
+ return nil unless user
133
+
134
+ hook = Studio.agent_chat_verified_email
135
+ email = if hook.respond_to?(:call) then hook.call(user)
136
+ elsif user.respond_to?(:email_verified_at) && user.email_verified_at.present? && user.respond_to?(:email)
137
+ user.email
138
+ end
139
+ email.to_s.strip.presence
140
+ end
141
+
142
+ # The token in the signed anon cookie, or nil when there is none or it is
143
+ # not one this app minted.
144
+ def anon_token
145
+ token = cookies.signed[ANON_COOKIE].to_s
146
+ token.match?(/\A[A-Za-z0-9_-]{32,64}\z/) ? token : nil
147
+ end
148
+
149
+ def render_hub_error(error)
150
+ case error
151
+ when Studio::AgentChat::Invalid then render_error(error.message, :unprocessable_entity)
152
+ when Studio::AgentChat::Capped
153
+ response.headers["Retry-After"] = error.retry_after.to_s if error.retry_after
154
+ render_error("You've sent a lot of messages. Wait a little, then try again.", :too_many_requests,
155
+ retry_after: error.retry_after)
156
+ when Studio::AgentChat::UnknownSoul then render_error("No chat here.", :not_found)
157
+ else
158
+ Rails.logger.warn("[studio] agent chat: #{error.class}: #{error.message}")
159
+ capture(error) if error.is_a?(Studio::AgentChat::Unauthorized) || action_name == "create"
160
+ render_error(UNAVAILABLE, :service_unavailable, retry: true)
161
+ end
162
+ end
163
+
164
+ # An ErrorLog row for a message that could not be sent, and for every
165
+ # refusal of the app's own credential (a configuration fault). A poll that
166
+ # fails is only logged: an open page polls every 15 seconds while the hub is
167
+ # down, and a row per poll would bury the outage it reports. Never raises.
168
+ def capture(error)
169
+ ::ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
170
+ rescue StandardError
171
+ nil
172
+ end
173
+
174
+ def render_error(message, status, **extra)
175
+ render json: { error: message, **extra }, status: status
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,286 @@
1
+ // studio/agent_chat: the shared agent chat's thread (studio/agent_chat/_chat;
2
+ // docs/AGENT_CHAT.md). The hub holds the conversation; this page asks its own
3
+ // app for it (GET /agent_chat/turns, POST /agent_chat/messages), which proxies.
4
+ //
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 textContent, never HTML.
7
+ //
8
+ // LOAD on connect, the thread's latest turns; a failure shows the offline
9
+ // notice with Try again (and retries itself, slowly).
10
+ // POLL answers arrive by polling, as on the hub's /build: every POLL_MS
11
+ // while an answer is pending, slowing to SLOW_MS once it has waited
12
+ // SLOW_AFTER_MS (a hub that is down is not hammered); every IDLE_MS
13
+ // otherwise, and not at all while the tab is hidden (it asks at once
14
+ // when the tab comes back). Each poll asks
15
+ // for turns AFTER the last settled one (afterCursor), so a turn still
16
+ // pending is asked about again until it settles.
17
+ // SEND Enter sends, Shift+Enter is a new line. The words stay in the box
18
+ // until the app answers 201; a refusal shows its reason above them.
19
+ // FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
20
+ // it, and always after their own send (the hub's chat_follow rule).
21
+ // TYPING the typing line shows while the hub says an answer is pending:
22
+ // "thinking", then "typing" once a turn's status is "typing".
23
+ //
24
+ // It imports nothing, so test/javascript/agent_chat.test.mjs loads it as a
25
+ // data: module; studio/controllers/agent_chat_controller mounts it.
26
+
27
+ export const POLL_MS = 2500
28
+ export const SLOW_MS = 10000
29
+ export const SLOW_AFTER_MS = 120000
30
+ export const IDLE_MS = 30000
31
+ export const OFFLINE_MS = 15000
32
+ export const FOLLOW_PX = 20
33
+ export const LIMIT = 50
34
+ const UNSETTLED = new Set(["pending", "typing"])
35
+
36
+ export function settled(turn) {
37
+ return !UNSETTLED.has(String(turn && turn.status))
38
+ }
39
+
40
+ // A turn's identity is its id AND its role: the hub answers one message row as
41
+ // two turns with the row's id, the visitor's words ("user") and, once answered,
42
+ // the agent's ("agent"). Keyed by id alone, the answer would replace the words.
43
+ export function turnKey(turn) {
44
+ return `${turn.id}:${turn.role === "agent" ? "agent" : "user"}`
45
+ }
46
+
47
+ // Merge `incoming` turns into `turns` (both oldest first): a known (id, role) is
48
+ // replaced in place. A new agent turn goes right after its own user turn (same
49
+ // id), so when the visitor sends A then B before A is answered, A's answer sits
50
+ // under A, not under B. Any other new turn is appended. Returns a new array.
51
+ export function mergeTurns(turns, incoming) {
52
+ const merged = turns.slice()
53
+ for (const turn of incoming || []) {
54
+ if (!turn || turn.id == null) continue
55
+ const key = turnKey(turn)
56
+ const at = merged.findIndex((known) => turnKey(known) === key)
57
+ if (at !== -1) { merged[at] = turn; continue }
58
+ const own = turn.role === "agent" ? merged.findIndex((known) => turnKey(known) === `${turn.id}:user`) : -1
59
+ if (own !== -1) merged.splice(own + 1, 0, turn)
60
+ else merged.push(turn)
61
+ }
62
+ return merged
63
+ }
64
+
65
+ // The `after` a poll sends: the id of the last turn before the first one still
66
+ // unsettled (so that one is asked about again), or of the last turn when all
67
+ // are settled. null asks for the latest turns. `after` is a hub ROW id (rows
68
+ // with id > after): an unsettled row's user turn is itself unsettled, so the
69
+ // cursor lands on an earlier row and the pending row is asked about again.
70
+ export function afterCursor(turns) {
71
+ const first = turns.findIndex((turn) => !settled(turn))
72
+ if (first === -1) return turns.length ? String(turns[turns.length - 1].id) : null
73
+ return first === 0 ? null : String(turns[first - 1].id)
74
+ }
75
+
76
+ // Milliseconds to the next poll.
77
+ export function pollDelay({ waiting, waitingSince, now, offline }) {
78
+ if (offline) return OFFLINE_MS
79
+ if (!waiting) return IDLE_MS
80
+ return now - (waitingSince ?? now) > SLOW_AFTER_MS ? SLOW_MS : POLL_MS
81
+ }
82
+
83
+ export function typingPhase(turns) {
84
+ return turns.some((turn) => turn.status === "typing") ? "typing" : "thinking"
85
+ }
86
+
87
+ export function isFollowing(log, px = FOLLOW_PX) {
88
+ return log.scrollHeight - log.clientHeight - log.scrollTop <= px
89
+ }
90
+
91
+ // The page's authenticity token: the form's own field, else the csrf meta.
92
+ export function csrfToken(form, doc = globalThis.document) {
93
+ const field = form && form.querySelector("input[name='authenticity_token']")
94
+ if (field && field.value) return field.value
95
+ const meta = doc && doc.querySelector("meta[name='csrf-token']")
96
+ return meta ? meta.getAttribute("content") : null
97
+ }
98
+
99
+ export function turnsUrl(base, { soul, after, limit = LIMIT }) {
100
+ const params = new URLSearchParams({ soul, limit: String(limit) })
101
+ if (after != null) params.set("after", after)
102
+ return `${base}${base.includes("?") ? "&" : "?"}${params}`
103
+ }
104
+
105
+ export function mountAgentChat(root, {
106
+ fetch: fetcher = globalThis.fetch.bind(globalThis),
107
+ doc = globalThis.document,
108
+ now = () => Date.now(),
109
+ setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
110
+ clearTimer = (id) => globalThis.clearTimeout(id)
111
+ } = {}) {
112
+ const $ = (selector) => root.querySelector(selector)
113
+ const log = $("[data-agent-chat-log]")
114
+ const typing = $("[data-agent-chat-typing]")
115
+ const phase = $("[data-agent-chat-phase]")
116
+ const offlineNote = $("[data-agent-chat-offline]")
117
+ const retry = $("[data-agent-chat-retry]")
118
+ const form = $("[data-agent-chat-form]")
119
+ const input = $("[data-agent-chat-input]")
120
+ const send = $("[data-agent-chat-send]")
121
+ const error = $("[data-agent-chat-error]")
122
+ const status = $("[data-agent-chat-status]")
123
+ const soul = root.dataset.agentChatSoul
124
+ const name = root.dataset.agentChatName || "The agent"
125
+
126
+ const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, sending: false, timer: null, alive: true, following: true }
127
+
128
+ const onScroll = () => { state.following = isFollowing(log) }
129
+ log.addEventListener("scroll", onScroll, { passive: true })
130
+
131
+ function toEnd() { log.scrollTop = log.scrollHeight }
132
+
133
+ function setOffline(offline) {
134
+ state.offline = offline
135
+ if (offlineNote) offlineNote.hidden = !offline
136
+ }
137
+
138
+ function showError(text) {
139
+ if (!error) return
140
+ error.textContent = text || ""
141
+ error.hidden = !text
142
+ }
143
+
144
+ function render() {
145
+ const follow = state.following
146
+ for (const turn of state.turns) {
147
+ const key = turnKey(turn)
148
+ let node = Array.from(log.querySelectorAll("[data-turn-key]")).find((el) => el.dataset.turnKey === key)
149
+ if (!node) {
150
+ const template = root.querySelector(`template[data-agent-chat-template="${turn.role === "agent" ? "agent" : "user"}"]`)
151
+ if (!template) continue
152
+ node = template.content.firstElementChild.cloneNode(true)
153
+ node.dataset.turnId = String(turn.id)
154
+ node.dataset.turnKey = key
155
+ // Placed where mergeTurns put it: after the previous turn's element.
156
+ const index = state.turns.indexOf(turn)
157
+ const prevKey = index > 0 ? turnKey(state.turns[index - 1]) : null
158
+ const prev = prevKey && Array.from(log.querySelectorAll("[data-turn-key]")).find((el) => el.dataset.turnKey === prevKey)
159
+ if (prev) prev.after(node)
160
+ else log.appendChild(node)
161
+ }
162
+ node.dataset.status = turn.status || ""
163
+ const text = node.querySelector("[data-agent-chat-text]")
164
+ if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
165
+ const failed = node.querySelector("[data-agent-chat-failed]")
166
+ if (failed) failed.hidden = turn.status !== "failed"
167
+ }
168
+ const waiting = state.pending || state.turns.some((turn) => !settled(turn))
169
+ if (typing) typing.hidden = !waiting
170
+ if (phase) phase.dataset.phase = typingPhase(state.turns)
171
+ if (follow) toEnd()
172
+ }
173
+
174
+ function schedule() {
175
+ if (!state.alive) return
176
+ clearTimer(state.timer)
177
+ const waiting = state.pending || state.turns.some((turn) => !settled(turn))
178
+ if (waiting) state.waitingSince = state.waitingSince || now()
179
+ else state.waitingSince = null
180
+ state.timer = setTimer(poll, pollDelay({ waiting, waitingSince: state.waitingSince, now: now(), offline: state.offline }))
181
+ }
182
+
183
+ async function poll() {
184
+ if (!state.alive) return
185
+ const hidden = doc && doc.visibilityState === "hidden"
186
+ if (hidden || state.inFlight) { schedule(); return }
187
+ state.inFlight = true
188
+ try {
189
+ const response = await fetcher(turnsUrl(root.dataset.agentChatTurnsUrl, { soul, after: afterCursor(state.turns) }), {
190
+ headers: { Accept: "application/json" }, credentials: "same-origin"
191
+ })
192
+ if (!response.ok) throw new Error(`turns ${response.status}`)
193
+ const data = await response.json()
194
+ state.turns = mergeTurns(state.turns, data.turns)
195
+ state.pending = data.pending === true
196
+ setOffline(false)
197
+ render()
198
+ } catch (_error) {
199
+ setOffline(true)
200
+ } finally {
201
+ state.inFlight = false
202
+ schedule()
203
+ }
204
+ }
205
+
206
+ async function submit(event) {
207
+ if (event) event.preventDefault()
208
+ const text = (input.value || "").trim()
209
+ if (!text || state.sending) return
210
+ state.sending = true
211
+ if (send) send.disabled = true
212
+ showError("")
213
+ if (status) status.textContent = `Sending to ${name}…`
214
+ try {
215
+ const headers = { Accept: "application/json", "Content-Type": "application/json" }
216
+ const token = csrfToken(form, doc)
217
+ if (token) headers["X-CSRF-Token"] = token
218
+ const response = await fetcher(form.getAttribute("action"), {
219
+ method: "POST", headers, credentials: "same-origin", body: JSON.stringify({ soul, text })
220
+ })
221
+ let data = {}
222
+ try { data = await response.json() } catch (_e) { data = {} }
223
+ if (response.status === 201 && data.turn) {
224
+ input.value = ""
225
+ state.turns = mergeTurns(state.turns, [data.turn])
226
+ state.pending = true
227
+ state.following = true
228
+ setOffline(false)
229
+ render()
230
+ toEnd()
231
+ if (status) status.textContent = "Sent."
232
+ state.waitingSince = now()
233
+ clearTimer(state.timer)
234
+ state.timer = setTimer(poll, POLL_MS)
235
+ } else {
236
+ showError(data.error || `Couldn't send that to ${name}. Try again.`)
237
+ if (status) status.textContent = ""
238
+ }
239
+ } catch (_error) {
240
+ showError(`Can't reach ${name} right now. Your message is still here; try again in a moment.`)
241
+ if (status) status.textContent = ""
242
+ } finally {
243
+ state.sending = false
244
+ if (send) send.disabled = false
245
+ }
246
+ }
247
+
248
+ function onKeydown(event) {
249
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return
250
+ event.preventDefault()
251
+ submit()
252
+ }
253
+
254
+ function onRetry() {
255
+ clearTimer(state.timer)
256
+ poll()
257
+ }
258
+
259
+ // A tab that comes back asks at once rather than waiting out an idle poll.
260
+ function onVisible() {
261
+ if (doc && doc.visibilityState === "visible" && !state.inFlight) { clearTimer(state.timer); poll() }
262
+ }
263
+
264
+ form && form.addEventListener("submit", submit)
265
+ input && input.addEventListener("keydown", onKeydown)
266
+ retry && retry.addEventListener("click", onRetry)
267
+ doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
268
+ root.dataset.agentChatReady = "true"
269
+ poll()
270
+
271
+ return {
272
+ state,
273
+ poll,
274
+ submit,
275
+ destroy() {
276
+ state.alive = false
277
+ clearTimer(state.timer)
278
+ log.removeEventListener("scroll", onScroll)
279
+ form && form.removeEventListener("submit", submit)
280
+ input && input.removeEventListener("keydown", onKeydown)
281
+ retry && retry.removeEventListener("click", onRetry)
282
+ doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
283
+ delete root.dataset.agentChatReady
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,20 @@
1
+ // agent-chat: the shared agent chat (studio/agent_chat/_chat), on the engine's
2
+ // Stimulus application (studio/stimulus registers it lazily, the first time a
3
+ // page renders a chat). It sits on the chat's section:
4
+ //
5
+ // <section data-studio-controller="agent-chat" data-agent-chat-soul="alex" ...>
6
+ //
7
+ // The loading, polling, sending and following are studio/agent_chat.
8
+ import { Controller } from "@hotwired/stimulus"
9
+ import { mountAgentChat } from "studio/agent_chat"
10
+
11
+ export default class extends Controller {
12
+ connect() {
13
+ this.chat = mountAgentChat(this.element)
14
+ }
15
+
16
+ disconnect() {
17
+ if (this.chat) this.chat.destroy()
18
+ this.chat = null
19
+ }
20
+ }
@@ -55,7 +55,9 @@ export const schema = {
55
55
  // board whose controller failed still renders, counts and toasts, and says that
56
56
  // its cards do not drag (data-studio-controller-failed="board"). So is the
57
57
  // knowledge transcript: a recording whose controller failed still plays from
58
- // its own controls, and its page says that click-to-play did not load.
58
+ // its own controls, and its page says that click-to-play did not load. So is the
59
+ // agent chat: a chat whose controller failed still renders, and its section
60
+ // carries data-studio-controller-failed="agent-chat".
59
61
  export const LAZY = {
60
62
  "board": () => import("studio/controllers/board_controller"),
61
63
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -64,7 +66,8 @@ export const LAZY = {
64
66
  "booking": () => import("studio/controllers/booking_controller"),
65
67
  "footer-map": () => import("studio/controllers/footer_map_controller"),
66
68
  "survey": () => import("studio/controllers/survey_controller"),
67
- "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller")
69
+ "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
70
+ "agent-chat": () => import("studio/controllers/agent_chat_controller")
68
71
  }
69
72
 
70
73
  export const application = Application.start(document.documentElement, schema)
@@ -0,0 +1,52 @@
1
+ <%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil) %>
2
+ <%#
3
+ The shared agent chat (docs/AGENT_CHAT.md): one conversation with one agent,
4
+ held by the hub, on any app's page.
5
+
6
+ render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: image_tag("alex.png", class: "w-24 h-24 rounded-full")
7
+
8
+ soul the agent's slug; must be on the app's Studio.agent_chat_souls.
9
+ name the agent's display name (header, composer, typing line).
10
+ portrait ready markup for the big round portrait; nil shows the initial.
11
+ role ready markup or text under the name.
12
+ empty the line an empty thread shows (default "Say hello to <name>.").
13
+ cards THE CARD SLOT: ready markup shown between the header and the
14
+ thread (/build's intake cards). A local, never a bare yield: a
15
+ partial rendered without a block yields the LAYOUT's body.
16
+
17
+ Top to bottom, as the hub's /build: the portrait and name, the card slot, the
18
+ thread, the typing line, the offline notice and the composer. The thread is
19
+ drawn by studio/agent_chat (the "agent-chat" controller, loaded lazily) from
20
+ the two <template>s below, which are studio/agent_chat/_turn: the look lives
21
+ here, in ERB, and the script only fills it. ONE PAGE HOLDS ONE CHAT.
22
+ %>
23
+ <% souls = Studio.agent_chat_soul_list %>
24
+ <% raise ArgumentError, "studio/agent_chat/chat: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
25
+ <section class="w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0"
26
+ data-studio-controller="agent-chat" data-test="agent-chat"
27
+ data-agent-chat-soul="<%= soul %>" data-agent-chat-name="<%= name %>"
28
+ data-agent-chat-messages-url="<%= studio_agent_chat_messages_path %>"
29
+ data-agent-chat-turns-url="<%= studio_agent_chat_turns_path %>">
30
+ <%= render "studio/agent_chat/header", name: name, portrait: portrait, role: role %>
31
+ <% if cards.present? %>
32
+ <div class="flex flex-col gap-3 min-w-0" data-agent-chat-cards data-test="agent-chat-cards"><%= cards %></div>
33
+ <% end %>
34
+ <div class="flex flex-col gap-3 min-w-0">
35
+ <div role="log" aria-live="polite" aria-label="Conversation with <%= name %>"
36
+ class="flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
37
+ data-agent-chat-log data-test="agent-chat-messages">
38
+ <p class="hidden only:block text-center text-secondary text-sm py-6" data-test="agent-chat-empty"><%= empty.presence || "Say hello to #{name}." %></p>
39
+ </div>
40
+ <div class="px-1" data-agent-chat-typing hidden data-test="agent-chat-typing-slot">
41
+ <%= render "studio/agent_chat/typing", name: name, portrait: portrait %>
42
+ </div>
43
+ <div class="rounded-2xl border border-subtle bg-inset px-4 py-3 flex flex-wrap items-center justify-between gap-3"
44
+ role="alert" data-agent-chat-offline hidden data-test="agent-chat-offline">
45
+ <p class="text-sm text-body min-w-0">Can't reach <%= name %> right now. Your messages are safe; try again in a moment.</p>
46
+ <button type="button" class="btn btn-secondary btn-sm shrink-0" data-agent-chat-retry data-test="agent-chat-retry">Try again</button>
47
+ </div>
48
+ <%= render "studio/agent_chat/form", soul: soul, name: name %>
49
+ </div>
50
+ <template data-agent-chat-template="user"><%= render "studio/agent_chat/turn", turn: { "role" => "user" }, name: name %></template>
51
+ <template data-agent-chat-template="agent"><%= render "studio/agent_chat/turn", turn: { "role" => "agent" }, name: name %></template>
52
+ </section>
@@ -0,0 +1,23 @@
1
+ <%# locals: (soul:, name:) %>
2
+ <%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. %>
3
+ <%= form_with url: studio_agent_chat_messages_path, method: :post, local: true,
4
+ html: { style: "view-transition-name: agent-composer;" },
5
+ class: "sticky bottom-3 z-10 rounded-3xl border border-subtle bg-surface shadow-lg focus-within:border-primary transition-colors p-2 flex flex-col gap-2",
6
+ data: { agent_chat_form: true, turbo: false, test: "agent-chat-form" } do |f| %>
7
+ <%= hidden_field_tag :soul, soul, id: nil %>
8
+ <p class="px-3 pt-1 text-xs text-danger-ink" role="alert" data-agent-chat-error hidden data-test="agent-chat-error"></p>
9
+ <p class="sr-only" role="status" aria-live="polite" data-agent-chat-status data-test="agent-chat-status"></p>
10
+ <div class="relative">
11
+ <%= label_tag :studio_agent_chat_text, "Message #{name}", class: "sr-only" %>
12
+ <%= text_area_tag :text, nil, id: "studio_agent_chat_text", rows: 2, maxlength: Studio::AgentChat::MAX_TEXT_LENGTH, required: true,
13
+ placeholder: "Message #{name}…", autocomplete: "off",
14
+ class: "block w-full min-w-0 resize-none bg-transparent pl-3 pr-14 py-2 text-base text-heading placeholder:text-muted focus:outline-none",
15
+ data: { agent_chat_input: true, test: "agent-chat-input" } %>
16
+ <button type="submit" class="absolute bottom-1 right-1 inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white disabled:opacity-40"
17
+ aria-label="Send to <%= name %>" data-agent-chat-send data-test="agent-chat-send">
18
+ <svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
19
+ <path d="M12 19V5M5 12l7-7 7 7"/>
20
+ </svg>
21
+ </button>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <%# locals: (name:, portrait: nil, role: nil) %>
2
+ <%# The chat's head (studio/agent_chat/_chat): the agent's portrait, a big circle centered on top, its name and role under it. `portrait` is ready markup; with none, the name's initial on a plain circle. The portrait keeps one view-transition name, as on the hub's agent pages. %>
3
+ <div class="flex flex-col items-center gap-2 text-center min-w-0" data-test="agent-chat-header" data-agent-name="<%= name %>">
4
+ <div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;" data-test="agent-chat-portrait">
5
+ <% if portrait.present? %>
6
+ <%= portrait %>
7
+ <% else %>
8
+ <span class="w-24 h-24 text-3xl rounded-full flex items-center justify-center bg-inset border border-subtle text-heading font-bold" aria-hidden="true"><%= name.to_s.first.to_s.upcase %></span>
9
+ <% end %>
10
+ </div>
11
+ <p class="text-sm text-secondary break-words" data-test="agent-chat-caption">
12
+ <span class="font-semibold text-heading"><%= name %></span><% if role.present? %> · <%= role %><% end %>
13
+ </p>
14
+ </div>
@@ -0,0 +1,19 @@
1
+ <%# locals: (turn:, name:) %>
2
+ <%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader. Text only, never HTML: the script sets textContent. A failed turn says so. %>
3
+ <% agent = turn["role"].to_s == "agent" %>
4
+ <% status = turn["status"].to_s %>
5
+ <div class="flex flex-col gap-2 min-w-0" data-agent-chat-turn data-role="<%= agent ? "agent" : "user" %>"
6
+ data-status="<%= status %>" data-turn-id="<%= turn["id"] %>"
7
+ data-turn-key="<%= "#{turn["id"]}:#{agent ? "agent" : "user"}" if turn["id"].present? %>" data-test="agent-chat-turn">
8
+ <% if agent %>
9
+ <div class="self-stretch min-w-0" data-test="agent-chat-reply">
10
+ <p class="sr-only"><%= name %>:</p>
11
+ <div class="text-body text-[0.9375rem] leading-relaxed whitespace-pre-wrap break-words" data-agent-chat-text><%= turn["text"] %></div>
12
+ </div>
13
+ <% else %>
14
+ <div class="self-end max-w-[85%] rounded-2xl rounded-br-sm px-3 py-2 bg-primary/15 text-primary-ink text-sm whitespace-pre-wrap break-words"
15
+ data-agent-chat-text data-test="agent-chat-sent"><%= turn["text"] %></div>
16
+ <% end %>
17
+ <p class="<%= agent ? "self-start" : "self-end" %> text-xs text-danger-ink" data-agent-chat-failed data-test="agent-chat-failed"
18
+ <%= "hidden" unless status == "failed" %>><%= name %> could not answer this one. Send it again.</p>
19
+ </div>
@@ -0,0 +1,18 @@
1
+ <%# locals: (name:, portrait: nil) %>
2
+ <%# A pending answer (studio/agent_chat/_chat), as the hub's agent_chats/_typing: THINKING (a small portrait in a turning ring, "<name> is thinking" shimmering) until the hub says the answer has begun, then TYPING (a bubble of wave dots). Both are drawn in one grid cell so the swap never moves the page; data-phase picks one (studio/agent_chat sets it). The motion is engine-motion.css (.studio-agent-typing) and holds still under reduced motion. %>
3
+ <div class="studio-agent-typing" data-phase="thinking" data-agent-chat-phase data-test="agent-chat-typing">
4
+ <div class="studio-agent-typing-layer studio-agent-typing-thinks" data-test="agent-chat-thinking">
5
+ <span class="studio-agent-typing-orbit" aria-hidden="true">
6
+ <span class="w-10 h-10 text-sm rounded-full flex items-center justify-center bg-inset border border-subtle text-heading font-bold shrink-0 overflow-hidden [&>img]:w-full [&>img]:h-full [&>img]:object-cover [&>svg]:w-full [&>svg]:h-full">
7
+ <% if portrait.present? %><%= portrait %><% else %><%= name.to_s.first.to_s.upcase %><% end %>
8
+ </span>
9
+ </span>
10
+ <span class="studio-agent-typing-label studio-agent-typing-shimmer"><%= name %> is thinking</span>
11
+ </div>
12
+ <div class="studio-agent-typing-layer studio-agent-typing-types" data-test="agent-chat-typing-bubble">
13
+ <span class="studio-agent-typing-bubble" aria-hidden="true">
14
+ <span class="studio-agent-typing-dot"></span><span class="studio-agent-typing-dot"></span><span class="studio-agent-typing-dot"></span>
15
+ </span>
16
+ <span class="studio-agent-typing-label"><%= name %> is typing</span>
17
+ </div>
18
+ </div>
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "zlib"
8
+ require "active_support/core_ext/object/blank"
9
+ require "active_support/core_ext/object/to_query"
10
+
11
+ module Studio
12
+ # The hub client behind the shared agent chat (docs/AGENT_CHAT.md). An app
13
+ # never stores a chat message: the McRitchie Studio hub holds every thread,
14
+ # one per (person, soul), and this client is the app's server talking to it.
15
+ # The browser only ever talks to its own app (Studio::AgentChatsController).
16
+ #
17
+ # THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
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
21
+ # GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
22
+ # 200 {turns: [...], pending: bool}
23
+ #
24
+ # `person` is {email:} for a signed-in user, else {anon_token:}; a signed-in
25
+ # POST that still holds the visitor's anon token sends {email:, anon_token:},
26
+ # and the hub folds the anonymous thread into the person's. A turn is
27
+ # {id, role: "user"|"agent", text, created_at, status} with status
28
+ # pending|typing|answered|failed.
29
+ #
30
+ # AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
31
+ # else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
32
+ # plain-http hub URL is refused unless its host is this machine (a developer
33
+ # pointing at a local hub).
34
+ #
35
+ # EVERY CALL IS BOUNDED: OPEN_TIMEOUT to connect and finish TLS, then
36
+ # READ_TIMEOUT (and WRITE_TIMEOUT) per socket operation; Net::HTTP's silent
37
+ # retry of an idempotent request is off, so a GET costs at most one wait.
38
+ #
39
+ # ERRORS. Every failure is a Studio::AgentChat::Error, so a caller rescues one
40
+ # class; the subclasses say what the visitor is told:
41
+ #
42
+ # Unavailable the hub could not be reached or answered 5xx / garbage, or
43
+ # the app is not configured (no token, a bad URL). "Try again."
44
+ # Unauthorized 401/403: the hub refused THIS APP's credential. A
45
+ # configuration fault, shown to the visitor as Unavailable.
46
+ # UnknownSoul 404: the hub has no web chat for that soul for this app.
47
+ # Invalid 422: the hub refused the message; #message is its reason.
48
+ # Capped 429: too many messages; #retry_after is seconds, or nil.
49
+ module AgentChat
50
+ class Error < StandardError
51
+ attr_reader :status
52
+
53
+ def initialize(message = nil, status: nil)
54
+ super(message)
55
+ @status = status
56
+ end
57
+ end
58
+
59
+ class Unavailable < Error; end
60
+ class Unauthorized < Error; end
61
+ class UnknownSoul < Error; end
62
+ class Invalid < Error; end
63
+
64
+ class Capped < Error
65
+ attr_reader :retry_after
66
+
67
+ def initialize(message = nil, status: 429, retry_after: nil)
68
+ super(message, status: status)
69
+ @retry_after = retry_after
70
+ end
71
+ end
72
+
73
+ MESSAGES_PATH = "/api/v1/agent_chat/messages"
74
+ TURNS_PATH = "/api/v1/agent_chat/turns"
75
+ # Seconds to open the connection and finish TLS.
76
+ OPEN_TIMEOUT = 3
77
+ # Seconds for any one read or write once connected. A post returns as soon
78
+ # as the hub has stored the message; the agent's answer arrives by polling.
79
+ READ_TIMEOUT = 8
80
+ WRITE_TIMEOUT = 8
81
+ MAX_LIMIT = 50
82
+ # The longest message the app forwards; the hub may refuse a shorter one.
83
+ MAX_TEXT_LENGTH = 4_000
84
+ # A hub answer larger than this is not a chat answer.
85
+ MAX_BODY_BYTES = 1_048_576
86
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
87
+ NETWORK_ERRORS = [
88
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
89
+ SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
90
+ Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error
91
+ ].freeze
92
+
93
+ class << self
94
+ # Stores one message from `person` to `soul` and answers its turn (a Hash
95
+ # 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
98
+ payload = request(:post, MESSAGES_PATH, body: body)
99
+ turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
100
+ raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
101
+
102
+ turn
103
+ end
104
+
105
+ # The thread's turns after `after` (a turn id; nil for the latest), and
106
+ # whether an answer is still pending: {"turns" => [...], "pending" => bool}.
107
+ def turns(soul:, person:, after: nil, limit: MAX_LIMIT)
108
+ query = { soul: soul.to_s, person: person_param(person), limit: limit.to_i.clamp(1, MAX_LIMIT) }
109
+ query[:after] = after.to_s if after.present?
110
+ payload = request(:get, TURNS_PATH, query: query)
111
+ raise Unavailable.new("the hub answered with no turns", status: 200) unless payload.is_a?(Hash) && payload["turns"].is_a?(Array)
112
+
113
+ { "turns" => payload["turns"].select { |turn| turn.is_a?(Hash) }, "pending" => payload["pending"] == true }
114
+ end
115
+
116
+ def hub_url = Studio.agent_chat_hub_url.to_s
117
+ def token = Studio.agent_chat_token.presence || ENV["AGENT_CHAT_TOKEN"].presence
118
+
119
+ private
120
+
121
+ # {email:}, {anon_token:}, or both: a signed-in post that still holds the
122
+ # visitor's anon token sends both, and the hub folds that anonymous thread
123
+ # into the person's.
124
+ def person_param(person)
125
+ person = person.to_h.transform_keys(&:to_s)
126
+ email = person["email"].to_s.strip
127
+ token = person["anon_token"].to_s.strip
128
+ raise ArgumentError, "person needs an email or an anon_token" if email.empty? && token.empty?
129
+
130
+ { email: email.presence, anon_token: token.presence }.compact
131
+ end
132
+
133
+ def request(verb, path, body: nil, query: nil)
134
+ uri = endpoint(path)
135
+ uri.query = query.to_query if query
136
+ credential = token || raise(Unavailable, "no AGENT_CHAT_TOKEN: the app has no agent chat credential")
137
+
138
+ req = verb == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
139
+ req["Authorization"] = "Bearer #{credential}"
140
+ req["Accept"] = "application/json"
141
+ req["User-Agent"] = "studio-engine/#{Studio::VERSION} agent-chat"
142
+ if body
143
+ req["Content-Type"] = "application/json"
144
+ req.body = JSON.generate(body)
145
+ end
146
+
147
+ response = http_for(uri).start { |http| http.request(req) }
148
+ interpret(response)
149
+ rescue *NETWORK_ERRORS => e
150
+ raise Unavailable, "the hub could not be reached (#{e.class})"
151
+ end
152
+
153
+ def endpoint(path)
154
+ base = URI.parse(hub_url)
155
+ raise Unavailable, "agent_chat_hub_url is not an http(s) URL" unless base.is_a?(URI::HTTP) && base.host.present?
156
+ if base.scheme != "https" && !LOOPBACK_HOSTS.include?(base.host)
157
+ raise Unavailable, "agent_chat_hub_url must be https (plain http only to this machine)"
158
+ end
159
+
160
+ URI.join(base.to_s.end_with?("/") ? base.to_s : "#{base}/", path.delete_prefix("/"))
161
+ rescue URI::InvalidURIError
162
+ raise Unavailable, "agent_chat_hub_url is not a URL"
163
+ end
164
+
165
+ def http_for(uri)
166
+ http = Net::HTTP.new(uri.host, uri.port)
167
+ http.use_ssl = uri.scheme == "https"
168
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
169
+ http.open_timeout = OPEN_TIMEOUT
170
+ http.read_timeout = READ_TIMEOUT
171
+ http.write_timeout = WRITE_TIMEOUT
172
+ http.ssl_timeout = OPEN_TIMEOUT if http.use_ssl?
173
+ http.max_retries = 0
174
+ http
175
+ end
176
+
177
+ def interpret(response)
178
+ status = response.code.to_i
179
+ success = status.between?(200, 299)
180
+ payload = success ? parse(response.body) : parse_reason(response.body)
181
+ reason = payload.is_a?(Hash) ? payload["error"].presence : nil
182
+
183
+ case status
184
+ when 200..299 then payload
185
+ when 401, 403 then raise Unauthorized.new("the hub refused this app's credential (#{status})", status: status)
186
+ when 404 then raise UnknownSoul.new(reason || "no web chat for that soul", status: status)
187
+ when 422 then raise Invalid.new(reason || "the hub refused that message", status: status)
188
+ when 429 then raise Capped.new(reason || "too many messages", retry_after: retry_after(response))
189
+ else raise Unavailable.new("the hub answered #{status}", status: status)
190
+ end
191
+ end
192
+
193
+ def parse(body)
194
+ return nil if body.nil? || body.empty?
195
+ raise Unavailable, "the hub's answer is too large" if body.bytesize > MAX_BODY_BYTES
196
+
197
+ JSON.parse(body)
198
+ rescue JSON::ParserError
199
+ raise Unavailable, "the hub's answer is not JSON"
200
+ end
201
+
202
+ # A refusal's body, read only for its {"error"} reason; a body that is not
203
+ # JSON (a proxy's HTML error page) is no reason.
204
+ def parse_reason(body)
205
+ parse(body)
206
+ rescue Unavailable
207
+ nil
208
+ end
209
+
210
+ def retry_after(response)
211
+ value = response["Retry-After"].to_s
212
+ value.match?(/\A\d+\z/) ? value.to_i : nil
213
+ end
214
+ end
215
+ end
216
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.115.4.rc1"
2
+ VERSION = "0.116.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -47,6 +47,7 @@ require "studio/profile_image"
47
47
  require "studio/oauth_identity"
48
48
  require "studio/newsletter"
49
49
  require "studio/health_report"
50
+ require "studio/agent_chat"
50
51
  require "studio/username_generator"
51
52
  require "studio/public_user"
52
53
  require "studio/name_parts"
@@ -1313,6 +1314,34 @@ module Studio
1313
1314
  mattr_accessor :draw_knowledge_routes, default: false
1314
1315
  mattr_accessor :knowledge_agents, default: []
1315
1316
 
1317
+ # The shared agent chat (docs/AGENT_CHAT.md): `render "studio/agent_chat/chat",
1318
+ # soul:, name:, portrait:` on any page, answered by the hub's agents. The hub
1319
+ # holds every thread; the app's server proxies the browser's two calls
1320
+ # (Studio::AgentChatsController, Studio::AgentChat).
1321
+ #
1322
+ # agent_chat_souls is the app's ALLOW-LIST of agent slugs a page may chat
1323
+ # with (e.g. %w[alex]). It is the gate: an app that names no soul draws no
1324
+ # /agent_chat routes, and a request naming a soul off the list is a 404. The
1325
+ # browser's `soul` is never trusted beyond it, and the hub re-checks that this
1326
+ # app may address that soul.
1327
+ mattr_accessor :agent_chat_souls, default: []
1328
+ mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
1329
+ # The app's bearer credential for the hub. Leave nil to read
1330
+ # ENV["AGENT_CHAT_TOKEN"] (1Password apps.<slug>.agent-chat).
1331
+ mattr_accessor :agent_chat_token, default: nil
1332
+ # Which signed-in users the hub may know by email. An email the user only
1333
+ # CLAIMS (the profile page saves a new address and clears email_verified_at;
1334
+ # password signup saves any address) would open that person's hub thread on
1335
+ # every app, so by default only a user whose email_verified_at is present is
1336
+ # sent by email; everyone else chats as an anonymous visitor (the anon
1337
+ # cookie). Set a callable ->(user) { "proven@address" or nil } to use another
1338
+ # proof; nil keeps the default. Never answer a claimed address.
1339
+ mattr_accessor :agent_chat_verified_email, default: nil
1340
+
1341
+ def self.agent_chat_soul_list
1342
+ Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
1343
+ end
1344
+
1316
1345
  class S3ConfigError < StandardError; end
1317
1346
 
1318
1347
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -1702,6 +1731,16 @@ module Studio
1702
1731
  constraints: { token: %r{[^/]+} }
1703
1732
  end
1704
1733
 
1734
+ # The shared agent chat's proxy (Studio::AgentChatsController): the
1735
+ # browser's two calls, which the app's server forwards to the hub. Drawn
1736
+ # only for an app that names a soul (Studio.agent_chat_souls).
1737
+ if Studio.agent_chat_soul_list.any?
1738
+ post "agent_chat/messages", to: "studio/agent_chats#create", as: :studio_agent_chat_messages,
1739
+ defaults: { format: :json }
1740
+ get "agent_chat/turns", to: "studio/agent_chats#turns", as: :studio_agent_chat_turns,
1741
+ defaults: { format: :json }
1742
+ end
1743
+
1705
1744
  # The session-drift rehydrate endpoint (Studio::SessionStatesController).
1706
1745
  # OPT-IN — see Studio.draw_session_routes. JSON only: the browser store is
1707
1746
  # its one caller, and an HTML answer would only ever be a mistake.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.115.4.rc1
4
+ version: 0.116.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -395,6 +395,7 @@ files:
395
395
  - app/controllers/sessions_controller.rb
396
396
  - app/controllers/solana_sessions_controller.rb
397
397
  - app/controllers/studio/admin_surveys_controller.rb
398
+ - app/controllers/studio/agent_chats_controller.rb
398
399
  - app/controllers/studio/bookings_controller.rb
399
400
  - app/controllers/studio/component_previews_controller.rb
400
401
  - app/controllers/studio/emails_controller.rb
@@ -424,6 +425,7 @@ files:
424
425
  - app/helpers/studio_navbar_helper.rb
425
426
  - app/helpers/studio_sidebar_helper.rb
426
427
  - app/helpers/studio_theme_helper.rb
428
+ - app/javascript/studio/agent_chat.js
427
429
  - app/javascript/studio/alpine_scopes.js
428
430
  - app/javascript/studio/alpine_shims.js
429
431
  - app/javascript/studio/alpine_stores.js
@@ -432,6 +434,7 @@ files:
432
434
  - app/javascript/studio/board.js
433
435
  - app/javascript/studio/booking.js
434
436
  - app/javascript/studio/confirm_interstitial.js
437
+ - app/javascript/studio/controllers/agent_chat_controller.js
435
438
  - app/javascript/studio/controllers/board_controller.js
436
439
  - app/javascript/studio/controllers/booking_controller.js
437
440
  - app/javascript/studio/controllers/email_banner_scale_controller.js
@@ -559,6 +562,11 @@ files:
559
562
  - app/views/studio/admin_models/show.html.erb
560
563
  - app/views/studio/admin_surveys/index.html.erb
561
564
  - app/views/studio/admin_surveys/show.html.erb
565
+ - app/views/studio/agent_chat/_chat.html.erb
566
+ - app/views/studio/agent_chat/_form.html.erb
567
+ - app/views/studio/agent_chat/_header.html.erb
568
+ - app/views/studio/agent_chat/_turn.html.erb
569
+ - app/views/studio/agent_chat/_typing.html.erb
562
570
  - app/views/studio/banners/_app_banner.html.erb
563
571
  - app/views/studio/banners/_button.html.erb
564
572
  - app/views/studio/banners/_dev_mode_button.html.erb
@@ -733,6 +741,7 @@ files:
733
741
  - lib/generators/studio/site_identity/site_identity_generator.rb
734
742
  - lib/studio-engine.rb
735
743
  - lib/studio.rb
744
+ - lib/studio/agent_chat.rb
736
745
  - lib/studio/auth_labels.rb
737
746
  - lib/studio/booking.rb
738
747
  - lib/studio/cable.rb