envoy_ai 0.0.1 → 0.0.3

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +181 -2
  3. data/app/assets/stylesheets/envoy/chat.css +236 -0
  4. data/app/assets/stylesheets/envoy/console.css +50 -176
  5. data/app/controllers/envoy/conversations_controller.rb +1 -4
  6. data/app/controllers/envoy/panels_controller.rb +30 -0
  7. data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
  8. data/app/jobs/envoy/run_job.rb +25 -4
  9. data/app/models/envoy/conversation.rb +33 -6
  10. data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
  11. data/app/views/envoy/conversations/index.html.erb +0 -2
  12. data/app/views/envoy/conversations/new.html.erb +1 -7
  13. data/app/views/envoy/conversations/show.html.erb +1 -17
  14. data/app/views/envoy/messages/_error.html.erb +6 -0
  15. data/app/views/envoy/messages/_streaming.html.erb +1 -1
  16. data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
  17. data/app/views/envoy/panels/_panel.html.erb +3 -0
  18. data/app/views/envoy/panels/_transcript.html.erb +21 -0
  19. data/config/routes.rb +1 -1
  20. data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
  21. data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
  22. data/lib/envoy/engine.rb +6 -0
  23. data/lib/envoy/errors.rb +4 -0
  24. data/lib/envoy/guard.rb +13 -1
  25. data/lib/envoy/library.rb +82 -0
  26. data/lib/envoy/llm.rb +1 -1
  27. data/lib/envoy/page_surface.rb +34 -0
  28. data/lib/envoy/prompt.rb +55 -0
  29. data/lib/envoy/runner.rb +17 -3
  30. data/lib/envoy/surface.rb +90 -0
  31. data/lib/envoy/tool_definition.rb +12 -2
  32. data/lib/envoy/version.rb +1 -1
  33. data/lib/envoy.rb +59 -0
  34. metadata +12 -12
  35. data/app/controllers/envoy/system_prompts_controller.rb +0 -52
  36. data/app/models/envoy/system_prompt.rb +0 -24
  37. data/app/models/envoy/system_prompt_version.rb +0 -14
  38. data/app/views/envoy/system_prompts/_form.html.erb +0 -14
  39. data/app/views/envoy/system_prompts/edit.html.erb +0 -2
  40. data/app/views/envoy/system_prompts/index.html.erb +0 -14
  41. data/app/views/envoy/system_prompts/new.html.erb +0 -2
  42. data/app/views/envoy/system_prompts/show.html.erb +0 -15
  43. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
  44. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
  45. data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65af52593c07af82aa70d2e0f32876bebf053d6cd1cce31cdaf914fe76aac996
4
- data.tar.gz: a0c236d33db995bdbd8e1bcbb6ff7eab428ac6aba5d4294b7b49273efbda16c6
3
+ metadata.gz: 91cc30ece48d5717f72bedaa2a6bb2ca693e237411c89fcf394988a15d5f34ce
4
+ data.tar.gz: a581c3c845910a89433ca60189ea62657b66dac927f66ca829394978cd7074c4
5
5
  SHA512:
6
- metadata.gz: 98ff01d5c0134a18f4f39809b5e881418ea8e38d4117e84351ec4dd13e34db1a0b589f8cde715f6c07c147534f3af5fa96022254eceef09c8aec86d36bcaee8b
7
- data.tar.gz: abebd4786665308b9988f3bed11333f34b7603803a57e7cdd98eed26b205e86572626882629467a52fb204664e3d6f0a9760456d367df286185fdf14414743a2
6
+ metadata.gz: fc2294c5a6538b7daca827b967f69c87701ba103eb73cfe03910075efac5938f96a2fe4d593e77176ad6694258603199f7b4862feed2cca4b71d239927baeebd
7
+ data.tar.gz: f01ee674e09999df11d161ccc7c7f9cfea299e923cb656bfc06e69633bb6c958f78e2eb00454d2f984ef172f6e49f64408a2a07bb63848bf01ba474d256d40ae
data/README.md CHANGED
@@ -135,14 +135,19 @@ result) — a user can only see and post to their own conversations.
135
135
  ## 5. Migrations
136
136
 
137
137
  Envoy ships its own migrations (`envoy_conversations`, `envoy_messages`,
138
- `envoy_tool_calls`, `envoy_system_prompts`, `envoy_system_prompt_versions`,
139
- plus the FK linking a conversation to the system-prompt version it used):
138
+ `envoy_tool_calls`, plus `surface_key`/`context_key`/`prompt_key` columns on
139
+ `envoy_conversations` for surface-bound and DSL-prompt conversations):
140
140
 
141
141
  ```bash
142
142
  bin/rails envoy:install:migrations
143
143
  bin/rails db:migrate
144
144
  ```
145
145
 
146
+ If you installed Envoy before this version, `envoy:install:migrations` also
147
+ copies over a migration that drops `envoy_system_prompts` and
148
+ `envoy_system_prompt_versions` — see the Unreleased section of the CHANGELOG
149
+ before running `db:migrate` on an app with real rows in those tables.
150
+
146
151
  ## 6. Assets
147
152
 
148
153
  The engine ships two Stimulus controllers under its own `app/javascript`
@@ -341,6 +346,180 @@ bundle exec bin/test test/envoy/guard_test.rb
341
346
  bundle exec bin/test test/envoy/guard_test.rb:12
342
347
  ```
343
348
 
349
+ ## 12. Embedding a chat
350
+
351
+ The console (section 6 onward) is a full page. Most hosts instead want a small
352
+ chat panel bolted onto an existing page — "ask about this record" next to a
353
+ show view, say — without adopting the console's chrome or its mounted URL.
354
+ That's what a **surface** is for: it binds a page to a toolset, a model and a
355
+ prompt, and a mounted `POST` endpoint turns that binding into a Turbo Frame
356
+ you can drop anywhere.
357
+
358
+ ### Define a surface
359
+
360
+ ```ruby
361
+ # config/initializers/envoy.rb, alongside the toolset force-load block
362
+ Envoy.define_surface :today do
363
+ toolset :assistant # a registered Envoy.define_toolset key
364
+ model "claude-sonnet-4-5" # optional; falls back to config.default_model
365
+ system_prompt :today_page # a Prompt key (see below), or a literal String
366
+ read_only true # optional; hides `access :write` tools entirely
367
+
368
+ # Runs in the job for every turn — see the callout below.
369
+ context do |actor:, key:|
370
+ date = key.delete_prefix("day:")
371
+ "The user is looking at the training day for #{date}."
372
+ end
373
+ end
374
+ ```
375
+
376
+ `toolset` is required; `Envoy.validate!` (see below) fails fast if it names an
377
+ unregistered toolset or an unregistered `system_prompt` key. `model`,
378
+ `read_only` and `system_prompt` are all optional.
379
+
380
+ ### Define prompts and libraries
381
+
382
+ `Envoy.define_prompt` registers reusable system-prompt text, composable with
383
+ `use` (like a toolset) so shared framing lives in one place:
384
+
385
+ ```ruby
386
+ Envoy.define_prompt :safety_framing do
387
+ body "Never invent data; say when you don't know."
388
+ end
389
+
390
+ Envoy.define_prompt :today_page do
391
+ use :safety_framing
392
+ body { File.read(Rails.root.join("app/envoy/prompts/today_page.md")) }
393
+ end
394
+ ```
395
+
396
+ A block body stays lazy, so editing the file on disk is picked up on the next
397
+ `to_prepare` reload rather than requiring a boot.
398
+
399
+ `Envoy.define_library` is for reference documents the model should fetch on
400
+ demand rather than have preloaded into every turn's instructions — it
401
+ compiles to an ordinary toolset (so it composes via `use` on a real
402
+ toolset), exposing one `read_<key>` tool with a closed `enum` of document
403
+ keys:
404
+
405
+ ```ruby
406
+ Envoy.define_library :principles do
407
+ directory Rails.root.join("docs/principles") # README.md -> description, *.md -> documents
408
+ end
409
+
410
+ Envoy.define_toolset :assistant do
411
+ description "General assistant tools."
412
+ use :principles
413
+ end
414
+ ```
415
+
416
+ ### Wire a page to it
417
+
418
+ Any host controller can call `envoy_surface` — it's mixed into
419
+ `ActionController::Base` by the engine, no include needed:
420
+
421
+ ```ruby
422
+ class TodayController < ApplicationController
423
+ envoy_surface :today, context_key: -> { "day:#{@date.to_fs(:iso8601)}" }
424
+ end
425
+ ```
426
+
427
+ `context_key` is instance_exec'd in the controller, so instance variables the
428
+ action set (`@date` here) are in scope. The helper method `envoy_page_surface`
429
+ (available in views) returns `{ surface:, context_key: }`, or `nil` if the
430
+ controller never declared a surface — pages opt in, they don't get a bubble by
431
+ default.
432
+
433
+ > **A `context` block runs in the job.** There is no request, no `params` and
434
+ > no session — only `actor` and `key`. Everything it needs must be derivable
435
+ > from the `context_key`. It is also the authorization boundary: `surface` and
436
+ > `context_key` both arrive from the browser, so raise `Envoy::Forbidden` if
437
+ > this actor may not see this subject.
438
+
439
+ ### Render the panel
440
+
441
+ `POST envoy.panel_path` (mounted by `resource :panel, only: :create`) finds
442
+ or creates the conversation for `(actor, surface, context_key)` and renders it
443
+ inside a `turbo_frame_tag "envoy_panel"`. Point a `button_to` at it with a
444
+ matching frame already on the page — same "create-or-resume" semantics as
445
+ opening a tab twice, so re-clicking never duplicates the conversation:
446
+
447
+ ```erb
448
+ <% if (surface = envoy_page_surface) %>
449
+ <%= turbo_frame_tag "envoy_panel" %>
450
+ <%= button_to "Ask about this page", envoy.panel_path,
451
+ params: { surface: surface[:surface], context_key: surface[:context_key] },
452
+ data: { turbo_frame: "envoy_panel" } %>
453
+ <% end %>
454
+ ```
455
+
456
+ An unregistered `surface` param renders `404`; the endpoint is otherwise
457
+ subject to the same `authenticate`/`actor_resolver` config as every other
458
+ Envoy route, so a signed-out visitor never reaches it.
459
+
460
+ ### Style it
461
+
462
+ The panel (and the console) render the same
463
+ `app/views/envoy/panels/_transcript.html.erb` partial, styled entirely by
464
+ `app/assets/stylesheets/envoy/chat.css` — no body rules, no fixed widths, the
465
+ host owns the chrome around it. `console.css` pulls it in automatically
466
+ (`@import url("chat.css")`, so loading the console stylesheet already gives
467
+ you these rules too), or import it directly from the host's own stylesheet if
468
+ you don't want the console's chrome at all:
469
+
470
+ ```css
471
+ @import url("envoy/chat.css");
472
+ ```
473
+
474
+ Use the `url(...)` form, not a bare string — Propshaft only fingerprints
475
+ `url(...)` references in CSS, not `@import` strings, and its asset server
476
+ 404s on an undigested path.
477
+
478
+ Every color it uses is a CSS custom property, and every declaration falls
479
+ back to its own original console value when the var is unset, so it renders
480
+ reasonably with zero host configuration and themes cleanly by setting only
481
+ the vars below:
482
+
483
+ | Var | Affects | Fallback (console light mode) |
484
+ |---|---|---|
485
+ | `--envoy-surface` | message bubble / input background | `#ffffff` |
486
+ | `--envoy-surface-alt` | assistant bubble background *and* tool-call panel background | `#eff6ff` / `#f9fafb` (each rule keeps its own original shade until you set this) |
487
+ | `--envoy-ink` | body text | `#111827` |
488
+ | `--envoy-subtle` | role labels, hints, placeholder text | `#6b7280` |
489
+ | `--envoy-line` | borders | `#e5e7eb` |
490
+ | `--envoy-accent` | primary button background | `#111827` |
491
+ | `--envoy-accent-ink` | primary button text | `#ffffff` |
492
+ | `--envoy-warn` | the failed-turn error bubble | `#b91c1c` |
493
+ | `--envoy-radius` | corner rounding (message/tool/input/button each keep their own default until set) | `0.5rem` / `0.375rem` |
494
+
495
+ Setting `--envoy-surface-alt` or `--envoy-radius` themes every rule that uses
496
+ it identically; the console itself never sets them, so it keeps its original,
497
+ slightly more varied look.
498
+
499
+ ### Assets and boot-time validation
500
+
501
+ Pin the same two Stimulus controllers as the console (section 6) — the
502
+ composer for Enter-to-submit, the autoscroll controller (now scrolls its own
503
+ `.envoy-chat__log` container rather than the window, so it sticks the panel
504
+ without hijacking the host page's scroll position):
505
+
506
+ ```ruby
507
+ # config/importmap.rb
508
+ pin "envoy/controllers/composer_controller", to: "envoy/controllers/composer_controller.js"
509
+ pin "envoy/controllers/autoscroll_controller", to: "envoy/controllers/autoscroll_controller.js"
510
+ ```
511
+
512
+ Finally, call `Envoy.validate!` at the end of the `to_prepare` block that
513
+ force-loads your definitions, so a typo'd toolset or prompt key fails on boot
514
+ — not silently, inside a job, where the user just watches "working…" forever:
515
+
516
+ ```ruby
517
+ Rails.application.config.to_prepare do
518
+ Dir[Rails.root.join("app/envoy/**/*.rb")].each { |f| require_dependency f }
519
+ Envoy.validate!
520
+ end
521
+ ```
522
+
344
523
  ## The debug console
345
524
 
346
525
  Envoy ships a minimal chat UI mounted at whatever path the host chooses
@@ -0,0 +1,236 @@
1
+ /* Embeddable chat rules. No body, no page-level layout, no fixed widths —
2
+ the host owns the chrome. Every colour is a var with a fallback, so the
3
+ stylesheet stands alone in the console and themes cleanly in a host.
4
+
5
+ Public vars (all optional — every rule falls back to the console's own
6
+ light-mode palette when unset): --envoy-surface, --envoy-surface-alt,
7
+ --envoy-ink, --envoy-subtle, --envoy-line, --envoy-accent,
8
+ --envoy-accent-ink, --envoy-warn, --envoy-radius. */
9
+
10
+ /* Fills a flex-column container (an embedded, fixed-height panel) so the log
11
+ below can scroll internally. Inert when the parent isn't a flex container —
12
+ e.g. the full-page console, which keeps its natural height and page-scrolls —
13
+ because flex item properties only apply inside a flex parent. The embedding
14
+ host is responsible for giving this element a bounded height (a flex column
15
+ with a definite height); the transcript then fills it and scrolls its log. */
16
+ .envoy-chat {
17
+ display: flex;
18
+ flex-direction: column;
19
+ min-height: 0;
20
+ flex: 1 1 auto;
21
+ }
22
+
23
+ .envoy-chat__log {
24
+ flex: 1 1 auto;
25
+ min-height: 0;
26
+ overflow-y: auto;
27
+ overscroll-behavior: contain;
28
+ scrollbar-width: thin;
29
+ scrollbar-color: var(--envoy-subtle, #6b7280) transparent;
30
+ }
31
+ .envoy-chat__log::-webkit-scrollbar { width: 8px; }
32
+ .envoy-chat__log::-webkit-scrollbar-track { background: transparent; }
33
+ .envoy-chat__log::-webkit-scrollbar-thumb {
34
+ background: var(--envoy-subtle, #6b7280);
35
+ border-radius: 9999px;
36
+ }
37
+ .envoy-chat__log::-webkit-scrollbar-thumb:hover {
38
+ background: var(--envoy-ink, #111827);
39
+ }
40
+
41
+ /* Separate the transcript from the composer: in the flex column the log sits
42
+ flush against the input form, so the last message and the textarea run
43
+ together without a divider. */
44
+ .envoy-chat > form {
45
+ margin-top: 0.75rem;
46
+ padding-top: 0.75rem;
47
+ border-top: 1px solid var(--envoy-line, #e5e7eb);
48
+ }
49
+
50
+ /* ---------------------------------------------------------------------- */
51
+ /* Messages */
52
+ /* ---------------------------------------------------------------------- */
53
+
54
+ .envoy-msg {
55
+ border: 1px solid var(--envoy-line, #e5e7eb);
56
+ border-radius: var(--envoy-radius, 0.5rem);
57
+ padding: 1rem;
58
+ background: var(--envoy-surface, #ffffff);
59
+ }
60
+ .envoy-msg--assistant { background: var(--envoy-surface-alt, #eff6ff); }
61
+
62
+ .envoy-msg__role {
63
+ font-size: 0.75rem;
64
+ text-transform: uppercase;
65
+ color: var(--envoy-subtle, #6b7280);
66
+ margin-bottom: 0.25rem;
67
+ }
68
+
69
+ /* Subtle "working…" shimmer while a reply is being generated. */
70
+ .envoy-working { animation: envoy-working 1.4s ease-in-out infinite; }
71
+ @keyframes envoy-working {
72
+ 0%, 100% { opacity: 0.4; }
73
+ 50% { opacity: 0.95; }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .envoy-working { animation: none; opacity: 0.7; }
77
+ }
78
+
79
+ /* ---------------------------------------------------------------------- */
80
+ /* Rendered markdown body */
81
+ /* ---------------------------------------------------------------------- */
82
+
83
+ .envoy-md { font-size: 0.9rem; line-height: 1.6; }
84
+ .envoy-md > * + * { margin-top: 0.7rem; }
85
+ .envoy-md h1 { font-size: 1.3em; font-weight: 700; }
86
+ .envoy-md h2 { font-size: 1.15em; font-weight: 700; }
87
+ .envoy-md h3, .envoy-md h4 { font-size: 1em; font-weight: 700; }
88
+ .envoy-md ul { padding-left: 1.25rem; list-style: disc; }
89
+ .envoy-md ol { padding-left: 1.25rem; list-style: decimal; }
90
+ .envoy-md li + li { margin-top: 0.2rem; }
91
+ .envoy-md a { text-decoration: underline; }
92
+ .envoy-md blockquote { border-left: 3px solid rgba(128, 128, 128, 0.4); padding-left: 0.75rem; }
93
+ .envoy-md hr { border: 0; border-top: 1px solid rgba(128, 128, 128, 0.3); }
94
+ /* Fenced code (comrak carries its own dark background + inline styles). */
95
+ .envoy-md pre { padding: 0.75rem 1rem; border-radius: 0.5rem; }
96
+ /* Inline code gets a subtle theme-agnostic chip. */
97
+ .envoy-md :not(pre) > code {
98
+ font-size: 0.9em;
99
+ overflow-wrap: anywhere;
100
+ background: rgba(128, 128, 128, 0.15);
101
+ padding: 0.1em 0.3em;
102
+ border-radius: 0.25rem;
103
+ }
104
+
105
+ /* Code blocks scroll within the message instead of spilling past it, with a
106
+ thin, theme-agnostic overlay scrollbar (mid-gray at 50% reads on both the
107
+ light tool-call panels and the dark highlighted blocks). Scoped to the
108
+ transcript log (.envoy-chat__log, see panels/_transcript.html.erb) rather
109
+ than an id, since message-list ids are conversation-scoped
110
+ (dom_id(conversation, :messages)) and no element has a literal
111
+ #envoy_messages id. */
112
+ .envoy-chat__log pre {
113
+ overflow-x: auto;
114
+ max-width: 100%;
115
+ scrollbar-width: thin;
116
+ scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
117
+ }
118
+ .envoy-chat__log pre::-webkit-scrollbar { height: 8px; }
119
+ .envoy-chat__log pre::-webkit-scrollbar-track { background: transparent; }
120
+ .envoy-chat__log pre::-webkit-scrollbar-thumb {
121
+ background: rgba(128, 128, 128, 0.5);
122
+ border-radius: 9999px;
123
+ }
124
+ .envoy-chat__log pre::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.8); }
125
+
126
+ /* ---------------------------------------------------------------------- */
127
+ /* Tool calls */
128
+ /* ---------------------------------------------------------------------- */
129
+
130
+ .envoy-tool {
131
+ margin-top: 0.5rem;
132
+ font-size: 0.875rem;
133
+ border: 1px solid var(--envoy-line, #e5e7eb);
134
+ border-radius: var(--envoy-radius, 0.375rem);
135
+ padding: 0.5rem;
136
+ background: var(--envoy-surface-alt, #f9fafb);
137
+ }
138
+
139
+ .envoy-tool__summary {
140
+ cursor: pointer;
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 0.5rem;
144
+ }
145
+
146
+ .envoy-tool pre {
147
+ font-size: 0.75rem;
148
+ margin-top: 0.5rem;
149
+ overflow-x: auto;
150
+ max-width: 100%;
151
+ }
152
+
153
+ .envoy-mono {
154
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
155
+ }
156
+
157
+ /* Tool-call outcome badge (rendered by Envoy::Badges.outcome). Colors match
158
+ the original Tailwind red-100/amber-100/green-100 pairs; intentionally
159
+ scheme-independent (not vars), as the original badge had no dark variant. */
160
+ .envoy-badge {
161
+ display: inline-block;
162
+ font-size: 0.75rem;
163
+ padding: 0.125rem 0.5rem;
164
+ border-radius: var(--envoy-radius, 0.375rem);
165
+ font-weight: 500;
166
+ }
167
+ .envoy-badge--ok { background: #dcfce7; color: #166534; }
168
+ .envoy-badge--failed { background: #fef3c7; color: #92400e; }
169
+ .envoy-badge--denied { background: #fee2e2; color: #991b1b; }
170
+
171
+ /* ---------------------------------------------------------------------- */
172
+ /* Composer */
173
+ /* ---------------------------------------------------------------------- */
174
+
175
+ .envoy-composer {
176
+ display: flex;
177
+ gap: 0.5rem;
178
+ align-items: flex-end;
179
+ }
180
+
181
+ .envoy-input {
182
+ flex: 1;
183
+ border: 1px solid var(--envoy-line, #e5e7eb);
184
+ border-radius: var(--envoy-radius, 0.375rem);
185
+ padding: 0.5rem;
186
+ background: var(--envoy-surface, #ffffff);
187
+ color: var(--envoy-ink, #111827);
188
+ }
189
+ .envoy-input::placeholder { color: var(--envoy-subtle, #6b7280); opacity: 1; }
190
+
191
+ .envoy-hint {
192
+ font-size: 0.75rem;
193
+ color: var(--envoy-subtle, #6b7280);
194
+ margin-top: 0.25rem;
195
+ }
196
+
197
+ /* ---------------------------------------------------------------------- */
198
+ /* Buttons */
199
+ /* ---------------------------------------------------------------------- */
200
+
201
+ .envoy-btn {
202
+ padding: 0.375rem 0.75rem;
203
+ border-radius: var(--envoy-radius, 0.375rem);
204
+ background: var(--envoy-accent, #111827);
205
+ color: var(--envoy-accent-ink, #ffffff);
206
+ border: 0;
207
+ cursor: pointer;
208
+ text-decoration: none;
209
+ display: inline-block;
210
+ flex-shrink: 0;
211
+ font: inherit;
212
+ }
213
+ .envoy-btn--secondary {
214
+ background: transparent;
215
+ color: inherit;
216
+ border: 1px solid var(--envoy-line, #e5e7eb);
217
+ }
218
+
219
+ /* ---------------------------------------------------------------------- */
220
+ /* Layout / state helpers */
221
+ /* ---------------------------------------------------------------------- */
222
+
223
+ .envoy-stack > * + * { margin-top: 1rem; }
224
+ .envoy-stack--sm > * + * { margin-top: 0.75rem; }
225
+
226
+ .envoy-muted {
227
+ color: var(--envoy-subtle, #6b7280);
228
+ font-size: 0.875rem;
229
+ }
230
+
231
+ /* A .envoy-msg modifier (see app/views/envoy/messages/_error.html.erb): turns
232
+ the assistant bubble's border and text red when a turn failed. */
233
+ .envoy-error {
234
+ border-color: var(--envoy-warn, #b91c1c);
235
+ color: var(--envoy-warn, #b91c1c);
236
+ }