insika 0.9.0 → 1.0.1

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 (257) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -2
  3. data/README.md +5 -3
  4. data/bin/insika +3 -0
  5. data/bin/insika-router +0 -1
  6. data/docs/AGENTS.md +90 -7
  7. data/docs/ARCHITECTURE.md +12 -7
  8. data/docs/ARTIFACTS.md +33 -3
  9. data/docs/BENCHMARK.md +50 -7
  10. data/docs/CONTEXT.md +63 -0
  11. data/docs/CROSS-HARNESS-BENCH.md +157 -0
  12. data/docs/DEPLOY.md +57 -11
  13. data/docs/EMBEDDING.md +10 -0
  14. data/docs/EVALS.md +33 -4
  15. data/docs/FACTS.md +8 -6
  16. data/docs/HARVEST.md +8 -3
  17. data/docs/KNOWLEDGE.md +7 -0
  18. data/docs/MEDIA.md +6 -2
  19. data/docs/OBSERVABILITY.md +143 -22
  20. data/docs/PERFORMANCE.md +110 -0
  21. data/docs/PLUGINS.md +4 -4
  22. data/docs/POLICY.md +22 -13
  23. data/docs/RELEASING.md +5 -0
  24. data/docs/ROUTER.md +7 -0
  25. data/docs/RUBYLLM_2_MIGRATION.md +307 -0
  26. data/docs/SECURITY.md +7 -0
  27. data/docs/SHARED-CONVERSATIONS.md +40 -0
  28. data/docs/SKILLS.md +5 -3
  29. data/docs/STUDIO.md +122 -0
  30. data/docs/TOOLS.md +111 -9
  31. data/docs/operate.md +3 -0
  32. data/lib/insika/agent_defaults.rb +140 -0
  33. data/lib/insika/agent_file_store.rb +12 -0
  34. data/lib/insika/agent_profile.rb +78 -8
  35. data/lib/insika/artifact_signing.rb +11 -0
  36. data/lib/insika/chat_builder.rb +79 -25
  37. data/lib/insika/checkpoint.rb +6 -1
  38. data/lib/insika/checkpoint_store.rb +59 -7
  39. data/lib/insika/commands/agent_payload.rb +3 -3
  40. data/lib/insika/commands/delete_kit.rb +26 -0
  41. data/lib/insika/commands/gate_harvest.rb +3 -0
  42. data/lib/insika/commands/review_guardrail_flag.rb +38 -0
  43. data/lib/insika/commands/run_harvest.rb +17 -1
  44. data/lib/insika/commands/seed_session.rb +11 -1
  45. data/lib/insika/commands/send_message.rb +9 -3
  46. data/lib/insika/commands/update_settings.rb +23 -1
  47. data/lib/insika/commands/write_kit.rb +27 -0
  48. data/lib/insika/compaction.rb +33 -7
  49. data/lib/insika/config_store.rb +87 -7
  50. data/lib/insika/context/priority.rb +3 -0
  51. data/lib/insika/context/provider.rb +2 -2
  52. data/lib/insika/context/providers/knowledge.rb +25 -2
  53. data/lib/insika/context/providers/memory.rb +53 -4
  54. data/lib/insika/context/providers/pending_confirmation.rb +52 -0
  55. data/lib/insika/context/providers/prompt.rb +17 -4
  56. data/lib/insika/context/providers/session.rb +39 -3
  57. data/lib/insika/context/reranker.rb +75 -0
  58. data/lib/insika/distill.rb +22 -13
  59. data/lib/insika/doctor.rb +34 -4
  60. data/lib/insika/dsl/runtime.rb +36 -4
  61. data/lib/insika/dsl/server_boot.rb +4 -12
  62. data/lib/insika/dsl.rb +13 -5
  63. data/lib/insika/edge_limiter.rb +23 -15
  64. data/lib/insika/egress_guard.rb +11 -3
  65. data/lib/insika/env_schema.rb +33 -73
  66. data/lib/insika/evals/assertions.rb +171 -16
  67. data/lib/insika/evals/golden.rb +141 -4
  68. data/lib/insika/evals/judge.rb +19 -10
  69. data/lib/insika/evals/moderator_compare.rb +47 -0
  70. data/lib/insika/evals/report.rb +61 -9
  71. data/lib/insika/evals/runner.rb +44 -4
  72. data/lib/insika/evals/simulator.rb +10 -1
  73. data/lib/insika/evals/transport.rb +200 -0
  74. data/lib/insika/event_stream.rb +63 -7
  75. data/lib/insika/evidence.rb +29 -8
  76. data/lib/insika/executor.rb +668 -141
  77. data/lib/insika/harvest.rb +26 -14
  78. data/lib/insika/harvest_engine.rb +1 -0
  79. data/lib/insika/harvest_store.rb +12 -0
  80. data/lib/insika/http_client.rb +87 -4
  81. data/lib/insika/kits.rb +54 -0
  82. data/lib/insika/knowledge.rb +34 -20
  83. data/lib/insika/llm_configurator.rb +46 -4
  84. data/lib/insika/llm_provider_store.rb +1 -3
  85. data/lib/insika/llm_trace_store.rb +54 -0
  86. data/lib/insika/mcp_client.rb +27 -63
  87. data/lib/insika/mcp_live_tool.rb +55 -9
  88. data/lib/insika/mcp_store.rb +32 -1
  89. data/lib/insika/mcp_tool_registry.rb +69 -26
  90. data/lib/insika/media.rb +38 -25
  91. data/lib/insika/memory_store.rb +4 -3
  92. data/lib/insika/memory_trim.rb +60 -0
  93. data/lib/insika/middleware.rb +12 -3
  94. data/lib/insika/model_metrics_store.rb +285 -0
  95. data/lib/insika/model_registry.rb +110 -0
  96. data/lib/insika/model_resolver.rb +1 -1
  97. data/lib/insika/model_selection.rb +14 -21
  98. data/lib/insika/model_visible.rb +17 -6
  99. data/lib/insika/onboarding.rb +1 -0
  100. data/lib/insika/outcome_store.rb +1 -1
  101. data/lib/insika/overlay_tool_registry.rb +25 -2
  102. data/lib/insika/pack_importer.rb +54 -9
  103. data/lib/insika/packaging.rb +14 -5
  104. data/lib/insika/pending_action_store.rb +45 -18
  105. data/lib/insika/plugin/loader.rb +4 -2
  106. data/lib/insika/profile_source.rb +4 -0
  107. data/lib/insika/provider_error_classifier.rb +18 -3
  108. data/lib/insika/queue_policy.rb +7 -2
  109. data/lib/insika/refinement/proposer.rb +42 -17
  110. data/lib/insika/reliability.rb +12 -3
  111. data/lib/insika/router/app.rb +14 -5
  112. data/lib/insika/router/session_key.rb +35 -5
  113. data/lib/insika/safety/config.rb +20 -3
  114. data/lib/insika/safety/corpus.rb +10 -3
  115. data/lib/insika/safety/factory.rb +39 -1
  116. data/lib/insika/safety/input_guardrail.rb +21 -1
  117. data/lib/insika/safety/judge_moderator.rb +70 -0
  118. data/lib/insika/safety/output_validator.rb +29 -5
  119. data/lib/insika/safety/prompt_echo.rb +89 -0
  120. data/lib/insika/schema_guard.rb +53 -76
  121. data/lib/insika/server/app.rb +17 -8
  122. data/lib/insika/server/responses.rb +19 -1
  123. data/lib/insika/session_actor.rb +58 -28
  124. data/lib/insika/session_store.rb +159 -10
  125. data/lib/insika/settings_store.rb +47 -1
  126. data/lib/insika/shared_conversations.rb +305 -0
  127. data/lib/insika/skill_catalog.rb +28 -2
  128. data/lib/insika/skill_store.rb +6 -0
  129. data/lib/insika/soak/runner.rb +1 -1
  130. data/lib/insika/steer_injector.rb +7 -48
  131. data/lib/insika/store.rb +29 -1
  132. data/lib/insika/stores/json_value.rb +37 -0
  133. data/lib/insika/stores/memory.rb +22 -35
  134. data/lib/insika/stores/postgres.rb +295 -0
  135. data/lib/insika/stores/sqlite.rb +257 -43
  136. data/lib/insika/stores/turn_counter.rb +25 -0
  137. data/lib/insika/studio/app.rb +1453 -161
  138. data/lib/insika/studio/assets/dist/application.css +1 -1
  139. data/lib/insika/studio/assets/dist/application.js +25 -25
  140. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  141. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  142. data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  143. data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  144. data/lib/insika/studio/forms.rb +171 -9
  145. data/lib/insika/studio/nav_icons.rb +3 -0
  146. data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
  147. data/lib/insika/studio/views/_agent_picker.erb +15 -0
  148. data/lib/insika/studio/views/_agent_tab_config.erb +78 -242
  149. data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
  150. data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
  151. data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
  152. data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
  153. data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
  154. data/lib/insika/studio/views/_agents_master.erb +8 -12
  155. data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
  156. data/lib/insika/studio/views/_cfg_budget.erb +20 -0
  157. data/lib/insika/studio/views/_cfg_distill.erb +26 -0
  158. data/lib/insika/studio/views/_cfg_followup.erb +30 -0
  159. data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
  160. data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
  161. data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
  162. data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
  163. data/lib/insika/studio/views/_chats_master.erb +66 -0
  164. data/lib/insika/studio/views/_customers_master.erb +37 -0
  165. data/lib/insika/studio/views/_empty.erb +15 -0
  166. data/lib/insika/studio/views/_facts_master.erb +42 -0
  167. data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
  168. data/lib/insika/studio/views/_identity.erb +16 -0
  169. data/lib/insika/studio/views/_kpi.erb +17 -0
  170. data/lib/insika/studio/views/_message.erb +40 -2
  171. data/lib/insika/studio/views/_model_chart.erb +82 -0
  172. data/lib/insika/studio/views/_refinement_master.erb +38 -0
  173. data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +32 -9
  174. data/lib/insika/studio/views/_tool_users.erb +17 -0
  175. data/lib/insika/studio/views/_why.erb +53 -0
  176. data/lib/insika/studio/views/agent_detail.erb +17 -10
  177. data/lib/insika/studio/views/agents.erb +28 -16
  178. data/lib/insika/studio/views/approvals.erb +16 -11
  179. data/lib/insika/studio/views/artifact.erb +20 -7
  180. data/lib/insika/studio/views/artifacts.erb +19 -14
  181. data/lib/insika/studio/views/chats.erb +22 -29
  182. data/lib/insika/studio/views/customer.erb +88 -43
  183. data/lib/insika/studio/views/customers.erb +16 -26
  184. data/lib/insika/studio/views/evals.erb +49 -31
  185. data/lib/insika/studio/views/fact_detail.erb +114 -0
  186. data/lib/insika/studio/views/facts.erb +15 -116
  187. data/lib/insika/studio/views/followups.erb +63 -52
  188. data/lib/insika/studio/views/funnel.erb +104 -43
  189. data/lib/insika/studio/views/harvest.erb +85 -59
  190. data/lib/insika/studio/views/home.erb +138 -23
  191. data/lib/insika/studio/views/inbox.erb +36 -0
  192. data/lib/insika/studio/views/kits.erb +176 -0
  193. data/lib/insika/studio/views/knowledge.erb +92 -80
  194. data/lib/insika/studio/views/layout.erb +42 -11
  195. data/lib/insika/studio/views/live.erb +45 -0
  196. data/lib/insika/studio/views/mcp.erb +43 -13
  197. data/lib/insika/studio/views/models.erb +187 -0
  198. data/lib/insika/studio/views/parity.erb +38 -32
  199. data/lib/insika/studio/views/playground.erb +13 -9
  200. data/lib/insika/studio/views/refinement.erb +15 -224
  201. data/lib/insika/studio/views/refinement_detail.erb +243 -0
  202. data/lib/insika/studio/views/session.erb +152 -117
  203. data/lib/insika/studio/views/settings.erb +86 -22
  204. data/lib/insika/studio/views/skills.erb +53 -19
  205. data/lib/insika/studio/views/system_files.erb +7 -7
  206. data/lib/insika/studio/views/task.erb +45 -15
  207. data/lib/insika/studio/views/tasks.erb +35 -18
  208. data/lib/insika/studio/views/tools.erb +62 -73
  209. data/lib/insika/task_actor.rb +4 -1
  210. data/lib/insika/task_store.rb +67 -1
  211. data/lib/insika/telemetry/pricing.rb +16 -14
  212. data/lib/insika/telemetry/recorder.rb +103 -9
  213. data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
  214. data/lib/insika/telemetry.rb +12 -2
  215. data/lib/insika/templates/daily-digest/README.md +13 -0
  216. data/lib/insika/templates/daily-digest/agent.rb +22 -11
  217. data/lib/insika/testing/store_contract.rb +65 -0
  218. data/lib/insika/text_tool_call.rb +66 -0
  219. data/lib/insika/tick.rb +7 -1
  220. data/lib/insika/token_store.rb +5 -0
  221. data/lib/insika/tool_batch.rb +1 -1
  222. data/lib/insika/tool_catalog.rb +12 -2
  223. data/lib/insika/tool_definition.rb +10 -4
  224. data/lib/insika/tool_envelope.rb +165 -28
  225. data/lib/insika/tool_manifest.rb +1 -0
  226. data/lib/insika/tool_store.rb +3 -0
  227. data/lib/insika/tool_trace_store.rb +15 -8
  228. data/lib/insika/tool_usage_report.rb +2 -2
  229. data/lib/insika/tools/a2a_remote.rb +1 -1
  230. data/lib/insika/tools/agent_enum.rb +1 -1
  231. data/lib/insika/tools/confirm_pending.rb +112 -0
  232. data/lib/insika/tools/data_defined_tool.rb +23 -15
  233. data/lib/insika/tools/generate_image.rb +2 -2
  234. data/lib/insika/tools/load_knowledge.rb +7 -3
  235. data/lib/insika/tools/load_skill.rb +3 -3
  236. data/lib/insika/tools/present.rb +1 -8
  237. data/lib/insika/tools/remember.rb +7 -2
  238. data/lib/insika/tools/run_persona_eval.rb +3 -5
  239. data/lib/insika/tools/save_artifact.rb +16 -6
  240. data/lib/insika/tools/schedule_followup.rb +3 -3
  241. data/lib/insika/tools/stuck_signal.rb +3 -3
  242. data/lib/insika/tools/subagent.rb +5 -5
  243. data/lib/insika/tools/subagents.rb +3 -3
  244. data/lib/insika/tools/tool_search.rb +7 -5
  245. data/lib/insika/tools/tts.rb +2 -2
  246. data/lib/insika/tools/update_briefing.rb +4 -4
  247. data/lib/insika/turn_output.rb +2 -2
  248. data/lib/insika/turn_state.rb +18 -0
  249. data/lib/insika/turn_timing.rb +51 -14
  250. data/lib/insika/version.rb +1 -1
  251. data/lib/insika/wiring/graph.rb +81 -16
  252. data/lib/insika/workflow.rb +25 -64
  253. data/lib/insika.rb +19 -3
  254. metadata +69 -13
  255. data/lib/insika/commands/import_mcp_tools.rb +0 -48
  256. data/lib/insika/mcp_http_client.rb +0 -67
  257. data/lib/insika/mcp_tool_ingestor.rb +0 -165
@@ -0,0 +1,243 @@
1
+ <%# Refinement run detail — the DETAIL pane of the Refinement miller shell.
2
+ Full page (direct visit/refresh): the two-column shell — master run list
3
+ plus this pane. Turbo-Frame request (a row click, a Run/propose/resolve
4
+ POST's redirect): the pane alone, so the master's scroll state and
5
+ selection survive every interaction. @selected is a
6
+ Insika::RefinementStore::Run — its `status` picks what shows below the
7
+ findings: a gated proposal awaiting approval, what auto_apply already
8
+ changed, or why the gate refused. The review IS the product: a gated
9
+ proposal shows the exact edit, what it claims to fix, and how it scored
10
+ against the accepted baseline, because approving is a decision and a
11
+ decision needs those three things. %>
12
+ <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
13
+ <% frame_only = defined?(frame_only) && frame_only %>
14
+ <% unless frame_only %>
15
+ <header class="page-head">
16
+ <div>
17
+ <nav class="crumbs"><a href="/studio/refinement">refinement</a></nav>
18
+ <h1>Refinement<span class="sub"><%= @agent || "no agent" %></span></h1>
19
+ </div>
20
+ <div class="head-actions">
21
+ <%== agent_filter_form("/studio/refinement", @agent) %>
22
+ </div>
23
+ </header>
24
+
25
+ <div class="drill" data-controller="list-filter">
26
+ <%== render("_refinement_master", locals: { active_id: @selected&.id }) %>
27
+ <% end %>
28
+ <turbo-frame id="refinement-detail" class="drill-pane drill-detail">
29
+ <%# A frame response never sees the layout, so the flash strip lives here
30
+ too — without it, run/propose/resolve confirmations would vanish on
31
+ frame submits (the layout's copy only renders on full pages). %>
32
+ <% if request.env["HTTP_TURBO_FRAME"] == "refinement-detail" %>
33
+ <% if flash["error"] %><div class="flash err" role="alert"><%= flash["error"] %></div><% end %>
34
+ <% if flash["notice"] %><div class="flash ok" role="status"><%= flash["notice"] %></div><% end %>
35
+ <% end %>
36
+ <div class="drill-pane-head">
37
+ <div class="title">
38
+ <% if @selected %>
39
+ <strong>Conversation review</strong>
40
+ <span class="status <%= status_class(@selected.status) %>"><%= @selected.status %></span>
41
+ <span class="count">
42
+ <%= @selected.findings.size %> finding(s) · <%= refinement_window(@selected.window) %> ·
43
+ <%= refinement_elapsed(@selected) %>
44
+ </span>
45
+ <% else %>
46
+ <strong>No run yet</strong>
47
+ <span class="count">for <span class="mono"><%= @agent %></span></span>
48
+ <% end %>
49
+ </div>
50
+ <% if @agent %>
51
+ <form method="post" action="/studio/refinement" class="actions inline" data-turbo-frame="_top">
52
+ <%== csrf_tag %>
53
+ <input type="hidden" name="agent" value="<%= @agent %>">
54
+ <label class="check" title="Ignore the previous run and read the full window instead of only what happened since.">
55
+ <input type="checkbox" name="full" value="1"> full window
56
+ </label>
57
+ <button type="submit" class="btn primary btn-sm" data-turbo-submits-with="Running…">Run</button>
58
+ </form>
59
+ <% end %>
60
+ </div>
61
+ <div class="drill-pane-body refinement-review">
62
+ <% if @agent.nil? %>
63
+ <div class="empty">No agents yet. Create one first — a refinement run reads an agent's own traffic.</div>
64
+ <% elsif @selected.nil? %>
65
+ <div class="empty">No run yet for <span class="mono"><%= @agent %></span>. Press Run to read the last conversations.</div>
66
+ <% else %>
67
+ <%# The review IS the product. A gated proposal shows the exact
68
+ edit, what it claims to fix, and how it scored against the accepted baseline,
69
+ because approving is a decision and a decision needs those three things. %>
70
+ <% if @selected.status == :awaiting_approval %>
71
+ <div class="section">
72
+ <div class="section-head">
73
+ <h2>Proposal<span class="sub">gated · awaiting your approval</span></h2>
74
+ </div>
75
+ <div class="card">
76
+ <% cand = @selected.candidate || {} %>
77
+ <div class="skill-desc">
78
+ <span class="pill ok">gate passed</span>
79
+ <span class="mono"><%= @selected.gate["passed_cases"] %>/<%= @selected.gate["cases"] %> case(s)</span>
80
+ <span class="muted">no regression against <%= @selected.gate["baseline_cases"] %> baselined case(s)</span>
81
+ <span class="muted">· proposed by <span class="mono"><%= cand["proposer"] %></span></span>
82
+ <%# When a PANEL ran, the operator is looking at the winner of
83
+ several, and which ones it beat is part of the decision. %>
84
+ <% if @selected.panel.size > 1 %>
85
+ <span class="muted">· best of <%= @selected.panel.size %> candidate(s)</span>
86
+ <% end %>
87
+ <% if @selected.cost && @selected.cost["spent"].to_i.positive? %>
88
+ <span class="muted">· <%= @selected.cost["spent"] %> tokens<% if @selected.cost["cached"].to_i.positive? %>
89
+ (<%= @selected.cost["cached"] %> cached)<% end %></span>
90
+ <% end %>
91
+ </div>
92
+ <% if presence(cand["rationale"]) %>
93
+ <div class="skill-desc muted"><%= cand["rationale"] %></div>
94
+ <% end %>
95
+
96
+ <% Array(cand["edits"]).each do |e| %>
97
+ <div class="skill-desc">
98
+ <span class="pill"><%= e["op"] %></span>
99
+ <span class="mono"><%= e["file"] %></span>
100
+ <% if presence(e["anchor"]) %><span class="muted"><%= e["anchor"] %></span><% end %>
101
+ <% if Array(e["addresses"]).any? %>
102
+ <span class="muted">· addresses <%= Array(e["addresses"]).join(", ") %></span>
103
+ <% end %>
104
+ </div>
105
+ <pre class="diff"><% if e["op"] == "replace" %><span class="del">- <%= e["before"] %></span>
106
+ <% end %><span class="ins">+ <%= e["after"] %></span></pre>
107
+ <% end %>
108
+
109
+ <form method="post" action="/studio/refinement/resolve" class="actions" data-turbo-frame="_top">
110
+ <%== csrf_tag %>
111
+ <input type="hidden" name="agent" value="<%= @agent %>">
112
+ <input type="hidden" name="run_id" value="<%= @selected.id %>">
113
+ <label>
114
+ Note
115
+ <input type="text" name="note" placeholder="why (optional)">
116
+ </label>
117
+ <button type="submit" class="primary" name="decision" value="approved" data-turbo-submits-with="Applying…">Approve &amp; apply</button>
118
+ <button type="submit" class="danger" name="decision" value="rejected" data-turbo-submits-with="Rejecting…">Reject</button>
119
+ </form>
120
+ <div class="skill-desc muted">
121
+ Approving writes the edits to the agent's files and versions the previous
122
+ content — undo it any time from the file's History.
123
+ </div>
124
+ </div>
125
+
126
+ <%# The candidates that LOST. Shown because the panel's value is comparative: an
127
+ operator deciding whether the loop earns its keep needs to see that three
128
+ models were asked and what the other two wrote. %>
129
+ <% losers = @selected.panel.reject { |e| (e["candidate"] || {})["id"] == cand["id"] } %>
130
+ <% if losers.any? %>
131
+ <details class="card review-details">
132
+ <summary>Also proposed <span class="muted">· scored and not chosen</span></summary>
133
+ <ul class="history">
134
+ <% losers.each do |entry| %>
135
+ <% other = entry["candidate"] || {}; verdict = entry["gate"] || {} %>
136
+ <li>
137
+ <span class="pill <%= verdict["passed"] ? 'ok' : 'err' %>"><%= verdict["passed"] ? "passed" : "no" %></span>
138
+ <span class="mono"><%= Array(entry["proposers"]).join(" + ") %></span>
139
+ <span class="preview muted">
140
+ <%= Array(other["edits"]).size %> edit(s)
141
+ <% if verdict["cases"].to_i.positive? %>· <%= verdict["passed_cases"] %>/<%= verdict["cases"] %> case(s)<% end %>
142
+ <% if presence(verdict["reason"]) %>· <%= verdict["reason"] %><% end %>
143
+ </span>
144
+ </li>
145
+ <% end %>
146
+ </ul>
147
+ </details>
148
+ <% end %>
149
+ </div>
150
+ <% end %>
151
+
152
+ <%# `mode: auto_apply` changed this agent's files with nobody in
153
+ the loop, so the page owes the operator exactly three things: what changed, why,
154
+ and where to undo it. The write versioned, so History IS the undo. %>
155
+ <% if @selected.status == :applied && (@selected.decision || {})["by"] == "auto_apply" %>
156
+ <div class="card">
157
+ <div class="skill-desc">
158
+ <span class="pill ok">auto-applied</span>
159
+ <span class="muted"><%= (@selected.decision || {})["note"] %> · <%= (@selected.decision || {})["at"] %></span>
160
+ </div>
161
+ <% Array((@selected.candidate || {})["edits"]).each do |e| %>
162
+ <div class="skill-desc">
163
+ <span class="pill"><%= e["op"] %></span>
164
+ <a class="mono" href="/studio/agents/<%= Rack::Utils.escape(@agent) %>?file=<%= Rack::Utils.escape(e["file"]) %>"><%= e["file"] %></a>
165
+ </div>
166
+ <pre class="diff"><% if e["op"] == "replace" %><span class="del">- <%= e["before"] %></span>
167
+ <% end %><span class="ins">+ <%= e["after"] %></span></pre>
168
+ <% end %>
169
+ <div class="skill-desc muted">
170
+ Undo it from the file's History — the previous content is one click away.
171
+ </div>
172
+ </div>
173
+ <% end %>
174
+
175
+ <%# A gate that refused is the most useful thing on this page and the easiest to
176
+ lose: without it, pressing Propose and getting nothing back reads as a bug. %>
177
+ <% if @selected.status == :rejected && @selected.gate.is_a?(Hash) && !@selected.gate_passed? %>
178
+ <div class="card">
179
+ <div class="skill-desc">
180
+ <span class="pill err">gate refused</span>
181
+ <span class="mono"><%= @selected.gate["reason"] %></span>
182
+ </div>
183
+ <% Array((@selected.candidate || {})["dropped"]).each do |d| %>
184
+ <div class="skill-desc muted">dropped <span class="mono"><%= d["file"] %></span> — <%= d["reason"] %></div>
185
+ <% end %>
186
+ </div>
187
+ <% end %>
188
+
189
+ <h2>Findings<span class="sub"><%= @selected.findings.size %> · <%= @selected.status %><% if @selected.excluded.to_i.positive? %> · <%= @selected.excluded %> turn(s) excluded<% end %> · <%= @selected.finished_at || @selected.started_at %></span></h2>
190
+
191
+ <%# The model writes the candidate, the gate scores it. Offered on
192
+ a finished report and only when nothing is already awaiting an answer: a second
193
+ proposal while one is pending would ask the operator to review two edits to the
194
+ same file that were never scored together. %>
195
+ <% if @selected.status == :completed && @selected.findings.any? %>
196
+ <div class="card">
197
+ <form method="post" action="/studio/refinement/propose" class="actions" data-turbo-frame="_top">
198
+ <%== csrf_tag %>
199
+ <input type="hidden" name="agent" value="<%= @agent %>">
200
+ <input type="hidden" name="run_id" value="<%= @selected.id %>">
201
+ <button type="submit" class="primary" data-turbo-submits-with="Evaluating…">Propose a fix</button>
202
+ </form>
203
+ <div class="skill-desc muted">
204
+ Asks this agent's configured proposer(s) for a small, anchored edit to its
205
+ allowlisted files, then scores each one by replaying the golden set against a
206
+ throwaway clone and keeps the best. Nothing is written until you approve it.
207
+ Takes a while and costs provider calls — the replay is a real conversation per
208
+ case, per candidate.
209
+ </div>
210
+ </div>
211
+ <% end %>
212
+
213
+ <% if @selected.status == :failed %>
214
+ <div class="empty">This run failed: <span class="mono"><%= @selected.error %></span></div>
215
+ <% elsif @selected.findings.empty? %>
216
+ <div class="empty">Nothing found in this window. Either traffic was clean, or the window was empty.</div>
217
+ <% else %>
218
+ <div class="card">
219
+ <ul class="history">
220
+ <% @selected.findings.each do |f| %>
221
+ <li>
222
+ <span class="pill"><%= f["kind"] %></span>
223
+ <span class="mono">×<%= f["count"] %></span>
224
+ <span class="preview"><%= f["title"] %></span>
225
+ <% if f["detail"] %><div class="skill-desc muted"><%= f["detail"] %></div><% end %>
226
+ <% if Array(f["sessions"]).any? %>
227
+ <div class="skill-desc muted">
228
+ <% Array(f["sessions"]).each do |sid| %>
229
+ <a class="mono" href="/studio/sessions/<%= Rack::Utils.escape(sid) %>"><%= sid[0, 12] %></a>
230
+ <% end %>
231
+ </div>
232
+ <% end %>
233
+ </li>
234
+ <% end %>
235
+ </ul>
236
+ </div>
237
+ <% end %>
238
+ <% end %>
239
+ </div>
240
+ </turbo-frame>
241
+ <% unless frame_only %>
242
+ </div>
243
+ <% end %>
@@ -8,95 +8,120 @@
8
8
  <%# Roda renders with plain locals — no local_assigns hash (Rails-ism). %>
9
9
  <% frame_only = defined?(frame_only) && frame_only %>
10
10
  <% unless frame_only %>
11
- <header class="page-head">
11
+ <header class="page-head operation-head">
12
12
  <div>
13
- <nav class="crumbs"><a href="/studio/chats">chats</a> / <span>session</span></nav>
14
- <h1>Session
15
- <span class="copy-id" data-controller="clipboard" data-clipboard-text-value="<%= @session.id %>">
16
- <span class="mono"><%= @session.id[0, 16] %></span>
17
- <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy" aria-label="Copy full session id">copy id</button>
18
- </span>
19
- <span class="sub"><%= Array(@session.messages).size %> message(s) · updated <%= @session.updated_at %></span>
20
- </h1>
13
+ <nav class="crumbs"><a href="/studio/chats<%= @agent ? "?agent=#{Rack::Utils.escape(@agent)}" : "" %>">chats</a></nav>
14
+ <h1>Chats<span class="sub">Read the conversation and its history</span></h1>
15
+ </div>
16
+ <div class="head-actions">
17
+ <a class="btn primary" href="/studio/playground">New conversation</a>
21
18
  </div>
22
- <a class="btn" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>">Continue in playground →</a>
23
19
  </header>
24
20
 
25
- <div class="drill" data-controller="list-filter">
26
- <aside class="drill-pane drill-master">
27
- <div class="drill-pane-head">
28
- <div class="title"><strong>Conversations <span class="count"><%= @sessions.size %></span></strong></div>
29
- </div>
30
- <div class="drill-search">
31
- <input type="search" class="filter-input" placeholder="Filter conversations…" aria-label="Filter conversations"
32
- data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
33
- </div>
34
- <nav class="drill-pane-body drill-list" aria-label="conversations" data-controller="active-link">
35
- <% @sessions.each do |s| %>
36
- <a class="drill-item<%== ' active' if s.id == @session.id %>"
37
- href="/studio/sessions/<%= Rack::Utils.escape(s.id) %>"
38
- data-turbo-frame="session-detail" data-turbo-action="advance"
39
- data-list-filter-target="item" data-filter-text="<%= s.id %> <%= session_preview(s) %>"
40
- data-active-link-target="item" data-action="click->active-link#select">
41
- <div class="drill-item-name"><span class="txt mono"><%= s.id[0, 18] %></span></div>
42
- <div class="drill-item-sub"><%= Array(s.messages).size %> msg · <%= time_ago(s.updated_at) %></div>
43
- </a>
44
- <% end %>
45
- </nav>
46
- </aside>
21
+ <div class="drill operation-workspace" data-controller="list-filter">
22
+ <%== render("_chats_master", locals: { active_id: @session.id }) %>
47
23
  <% end %>
48
- <turbo-frame id="session-detail" class="drill-pane drill-detail session-pane">
24
+ <turbo-frame id="session-detail" class="drill-pane drill-detail session-pane operation-detail">
49
25
  <div class="session-columns">
50
26
  <%# ── Center: the transcript ───────────────────────────────────── %>
51
- <section class="session-transcript" aria-label="transcript">
27
+ <%# live-transcript (T11): the SAME SSE island the playground uses,
28
+ scoped to THIS session — a turn landing on it while an operator has
29
+ it open here streams in live, right alongside the replayed history
30
+ below. The controller lives on THIS section, not the outer
31
+ turbo-frame: a row click in the master re-renders this whole
32
+ section from scratch (Turbo's default frame swap replaces the
33
+ frame's content wholesale, no morphing configured anywhere in this
34
+ app — see live_transcript_reconnect.test.mjs), so Stimulus tears
35
+ down the old controller (closing its EventSource) and boots a
36
+ fresh one scoped to the newly-selected session_id. Putting the
37
+ controller on the turbo-frame element itself would defeat this:
38
+ that element persists across the swap, so its session-value would
39
+ change under a live controller instance with no valueChanged
40
+ callback to notice — the stream would keep serving the PREVIOUS
41
+ session. %>
42
+ <section class="session-transcript" aria-label="transcript"
43
+ data-controller="live-transcript"
44
+ data-live-transcript-session-value="<%= @session.id %>"
45
+ data-live-transcript-why-value="/studio/sessions/<%= Rack::Utils.escape(@session.id) %>">
52
46
  <%# Session markers in the margin: start, agent,
53
47
  shape — ghost pills, right-aligned, from data the view already
54
48
  has. %>
55
49
  <% first_at = Array(@session.messages).first&.[]("at") %>
56
50
  <% marker_agent = session_agent(@session) %>
51
+ <header class="operation-summary">
52
+ <div class="section-head">
53
+ <h2><%= presence(session_customer_label(@session)) || "Conversation" %></h2>
54
+ <p class="muted"><%= marker_agent.empty? ? "No agent recorded" : marker_agent %><% if (phone = session_customer_phone(@session)) %> · <%= phone %><% end %> · <%= Array(@session.messages).size %> messages</p>
55
+ </div>
56
+ <div class="actions">
57
+ <span class="copy-id" data-controller="clipboard" data-clipboard-text-value="<%= @session.id %>">
58
+ <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy" aria-label="Copy full session id">copy id</button>
59
+ </span>
60
+ <span class="pill info" data-live-transcript-target="status" role="status">connecting…</span>
61
+ <a class="btn btn-sm" href="/studio/playground?session_id=<%= Rack::Utils.escape(@session.id) %>" data-turbo-frame="_top">Continue in playground</a>
62
+ <%# Narrow screens: the metadata margin is a popover drawer (no JS — the
63
+ Popover API toggles it, Esc and a click outside close it). %>
64
+ <button type="button" class="btn btn-sm details-toggle" popovertarget="session-details">Details</button>
65
+ </div>
66
+ </header>
57
67
  <div class="marker-row">
58
- <% unless short_time(first_at).empty? %><span class="ghost-pill">session · <%= short_time(first_at) %></span><% end %>
59
68
  <% unless marker_agent.empty? %><span class="ghost-pill">agent · <%= marker_agent %></span><% end %>
60
- <span class="ghost-pill"><%= Array(@session.messages).size %> messages</span>
69
+ <% unless short_time(first_at).empty? %><span class="ghost-pill">session · <%= short_time(first_at) %></span><% end %>
70
+ <span class="ghost-pill">updated <%= time_ago(@session.updated_at) %></span>
61
71
  </div>
62
72
 
63
- <% if Array(@session.messages).empty? %>
64
- <div class="empty">This session has no messages.</div>
65
- <% else %>
66
- <div class="thread">
67
- <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
68
- so its body is a persisted `role: tool` message. A skill injected by CONTEXT
69
- (triggers/skills_eager) is part of the system prompt — never a message — so
70
- the thread had no trace of it at all. Synthesized from the context trace so
71
- both activation paths read the same way in the transcript.
73
+ <div class="thread" data-live-transcript-target="stream">
74
+ <%# A skill loaded by the model shows up here on its own: load_skill is a tool,
75
+ so its body is a persisted `role: tool` message. A skill injected by CONTEXT
76
+ (triggers/skills_eager) is part of the system prompt — never a message — so
77
+ the thread had no trace of it at all. Synthesized from the context trace so
78
+ both activation paths read the same way in the transcript.
72
79
 
73
- Placed by TIME, not by counting turns: every message of a turn is persisted
74
- together at the end of it, and the context stage always runs before that, so
75
- each entry lands at the top of its own turn. Ordinal counting would drift on
76
- a steered message (one that joins a turn already running). A message with no
77
- `at` (older sessions) simply flushes nothing. %>
78
- <% pending = (@context_traces || [])
79
- .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
80
- .sort_by { |c| c["at"].to_s } %>
81
- <% Array(@session.messages).each_with_index do |m, i| %>
82
- <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
83
- <% pending.shift %>
84
- <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
85
- <details class="toolcard skill">
86
- <summary><%== chip_icon(:skill) %><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
87
- <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
88
- </details>
89
- <% end %>
90
- <%# `prev` is what makes a STEERED message visible: a customer message that lands
91
- right after a tool result arrived mid-run. See _message.erb. %>
92
- <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil }) %>
80
+ Placed by TIME, not by counting turns: every message of a turn is persisted
81
+ together at the end of it, and the context stage always runs before that, so
82
+ each entry lands at the top of its own turn. Ordinal counting would drift on
83
+ a steered message (one that joins a turn already running). A message with no
84
+ `at` (older sessions) simply flushes nothing. %>
85
+ <% pending = (@context_traces || [])
86
+ .select { |c| Array((c["categories"] || {}).dig("skilltrigger", "labels")).any? }
87
+ .sort_by { |c| c["at"].to_s } %>
88
+ <% Array(@session.messages).each_with_index do |m, i| %>
89
+ <% while (entry = pending.first) && !m["at"].to_s.empty? && entry["at"].to_s <= m["at"].to_s %>
90
+ <% pending.shift %>
91
+ <% labels = Array(entry["categories"]["skilltrigger"]["labels"]).uniq %>
92
+ <details class="toolcard skill">
93
+ <summary><%== chip_icon(:skill) %><strong>skills · <%= skill_mode(labels) %> (<%= labels.length %>)</strong><span class="at"><%= entry["at"] %></span></summary>
94
+ <pre><%= labels.map { |l| skill_label(l) }.join("\n") %></pre>
95
+ </details>
93
96
  <% end %>
94
- </div>
95
- <% end %>
97
+ <%# `prev` is what makes a STEERED message visible: a customer message that lands
98
+ right after a tool result arrived mid-run. See _message.erb. %>
99
+ <%== render("_message", locals: { m: m, prev: i.positive? ? @session.messages[i - 1] : nil,
100
+ review: { path: "/studio/sessions/#{Rack::Utils.escape_path(@session.id)}/guardrail", message: i } }) %>
101
+ <% if (w = @why && @why[i]) %><%== render("_why", locals: { w: w, sid: @session.id, index: i }) %><% end %>
102
+ <% end %>
103
+ </div>
104
+ <div class="empty" data-live-transcript-target="empty"<%== ' hidden' unless Array(@session.messages).empty? %>>This session has no messages.</div>
96
105
  </section>
97
106
 
98
107
  <%# ── Right margin: the session's metadata ──────────────────────── %>
99
- <aside class="session-margin" aria-label="session metadata">
108
+ <div class="col-resize" role="separator" aria-orientation="vertical" aria-label="Resize details" tabindex="0"
109
+ data-controller="col-resize" data-col-resize-key-value="session-details" data-col-resize-side-value="next"
110
+ data-action="pointerdown->col-resize#start keydown->col-resize#key dblclick->col-resize#reset"></div>
111
+ <aside class="session-margin" id="session-details" popover aria-label="session metadata">
112
+ <button type="button" class="btn btn-sm details-close" popovertarget="session-details" popovertargetaction="hide">Close</button>
113
+ <%# Who this conversation is with — the vars the caller sent, plus how
114
+ many conversations share the customer key (a link to them). %>
115
+ <% customer = presence(session_customer(@session)) %>
116
+ <% if customer %>
117
+ <div class="card">
118
+ <div class="trace-head"><h2>Customer</h2></div>
119
+ <div class="kv"><span>key</span><div class="mono"><%= customer %></div></div>
120
+ <% if (name = presence(session_customer_label(@session))) && name != customer %><div class="kv"><span>name</span><div><%= name %></div></div><% end %>
121
+ <% if (phone = session_customer_phone(@session)) %><div class="kv"><span>phone</span><div><%= phone %></div></div><% end %>
122
+ <div class="kv"><span>chats</span><div><a href="/studio/chats?q=<%= Rack::Utils.escape(customer) %>" data-turbo-frame="_top"><%= customer_chat_count(customer) %> conversation(s)</a></div></div>
123
+ </div>
124
+ <% end %>
100
125
  <% briefing = @session.respond_to?(:briefing) ? (@session.briefing || {}) : {} %>
101
126
  <% fields = briefing["fields"] || {} %>
102
127
  <% next_step = briefing["next_step"] %>
@@ -105,22 +130,50 @@
105
130
  <div class="trace-head">
106
131
  <h2>Briefing</h2>
107
132
  </div>
133
+ <% if next_step %>
134
+ <p class="operation-next"><span class="muted">next step</span><br><strong><%= next_step %></strong></p>
135
+ <% end %>
108
136
  <% fields.sort.each do |name, value| %>
109
137
  <div class="kv"><span><%= name %></span><div><%= value %></div></div>
110
138
  <% end %>
111
- <% if next_step %>
112
- <div class="kv"><span>next step</span><div><%= next_step %></div></div>
113
- <% end %>
114
139
  </div>
115
140
  <% end %>
116
141
 
117
- <% ctx_traces = @context_traces || [] %>
118
- <% unless ctx_traces.empty? %>
119
- <div class="card tooltrace">
142
+ <%# the session's stage history — outcomes folded to the agent's
143
+ declared stages (unmapped kinds render the raw outcome: the hole is visible).
144
+ Without a declaration the block renders the raw outcomes. %>
145
+ <% history = @stage_history || {} %>
146
+ <% unless history.empty? %>
147
+ <div class="card">
120
148
  <div class="trace-head">
121
- <h2>Context</h2>
122
- <span class="muted">tokens by category per turn (estimated)</span>
149
+ <h2>Stage history</h2>
150
+ <span class="muted">
151
+ <% if history[:declaration] %>
152
+ <%= history[:declaration].stages.join(" → ") %> · outcome funnel
153
+ <% else %>
154
+ no funnel declared — raw outcomes
155
+ <% end %>
156
+ </span>
157
+ </div>
158
+ <div class="kv">
159
+ <% history[:rows].each do |row| %>
160
+ <% if row[:stage] %>
161
+ <span><%= row[:outcome] %></span>
162
+ <div>→ <strong><%= row[:stage] %></strong> <span class="lat"><%= row[:at] %></span></div>
163
+ <% else %>
164
+ <span><%= row[:outcome] %></span>
165
+ <div><span class="lat">not a declared stage</span> <span class="lat"><%= row[:at] %></span></div>
166
+ <% end %>
167
+ <% end %>
123
168
  </div>
169
+ </div>
170
+ <% end %>
171
+
172
+ <% ctx_traces = @context_traces || [] %>
173
+ <% unless ctx_traces.empty? %>
174
+ <details class="card tooltrace operation-diagnostics">
175
+ <summary>Context <span class="muted">· <%= ctx_traces.size %> turns</span></summary>
176
+ <p class="muted">Estimated tokens by category for each turn.</p>
124
177
  <% ctx_traces.each do |c| %>
125
178
  <div class="trace-turn">
126
179
  <div class="turn-label">turn <%= c["turn"] %> · <%= c["at"] %></div>
@@ -169,20 +222,29 @@
169
222
  </details>
170
223
  </div>
171
224
  <% end %>
172
- </div>
225
+ </details>
173
226
  <% end %>
174
227
 
175
- <% traces = @tool_traces || {} %>
228
+ <% traces = @tool_traces || [] %>
176
229
  <% unless traces.empty? %>
177
- <div class="card tooltrace">
178
- <div class="trace-head">
179
- <h2>Tool calls</h2>
180
- <span class="muted">debug — args + response, masked</span>
181
- </div>
182
- <% traces.keys.sort.each do |turn| %>
230
+ <% errors = traces.flatten.count { |trace| !trace["ok"] || trace["gate"] } %>
231
+ <details class="card tooltrace operation-diagnostics">
232
+ <summary>Tool calls <span class="muted">· <%= traces.sum(&:size) %></span><% if errors.positive? %> <span class="status err"><%= errors %> failed or blocked</span><% end %></summary>
233
+ <p class="muted">Arguments and responses are masked.</p>
234
+ <% traces.each do |group| %>
183
235
  <div class="trace-turn">
184
- <div class="turn-label">turn <%= turn %></div>
185
- <% traces[turn].each do |t| %>
236
+ <div class="turn-label">turn <%= group.first["turn"] %></div>
237
+ <%# Waterfall: when each call ran inside the turn (SVG geometry, CSP-safe). %>
238
+ <% bars = trace_waterfall(group) %>
239
+ <% if bars.size > 1 %>
240
+ <svg class="waterfall" viewBox="0 0 100 <%= bars.size * 6 %>" preserveAspectRatio="none" role="img"
241
+ aria-label="<%= bars.map { |b| "#{b[:tool]} #{b[:ms]}ms" }.join(", ") %>">
242
+ <% bars.each_with_index do |b, i| %>
243
+ <rect class="<%= b[:ok] ? "ok" : "err" %>" x="<%= b[:x] %>" y="<%= i * 6 + 1 %>" width="<%= b[:w] %>" height="4" rx="0.6"><title><%= b[:tool] %> · <%= b[:ms] %>ms</title></rect>
244
+ <% end %>
245
+ </svg>
246
+ <% end %>
247
+ <% group.each do |t| %>
186
248
  <details class="trace-item <%= t["ok"] ? "ok" : "err" %>">
187
249
  <summary>
188
250
  <span class="badge"><%= t["gate"] ? "blocked: #{t["gate"]}" : (t["ok"] ? "ok" : "error") %></span>
@@ -190,6 +252,9 @@
190
252
  <span class="lat"><%= t["ms"] %>ms</span>
191
253
  <span class="at"><%= t["at"] %></span>
192
254
  </summary>
255
+ <% if (loaded = loaded_skill_name(t)) %>
256
+ <div class="kv"><span>skill</span><a class="mono" href="/studio/skills/<%= Rack::Utils.escape(loaded) %>" data-turbo-frame="_top"><%= loaded %></a></div>
257
+ <% end %>
193
258
  <div class="kv"><span>args</span>
194
259
  <div class="copywrap" data-controller="clipboard">
195
260
  <button type="button" class="copy-btn" data-clipboard-target="button" data-action="clipboard#copy">copy</button>
@@ -206,37 +271,7 @@
206
271
  <% end %>
207
272
  </div>
208
273
  <% end %>
209
- </div>
210
- <% end %>
211
-
212
- <%# the session's stage history — outcomes folded to the agent's
213
- declared stages (unmapped kinds render the raw outcome: the hole is visible).
214
- Without a declaration the block renders the raw outcomes. %>
215
- <% history = @stage_history || {} %>
216
- <% unless history.empty? %>
217
- <div class="card">
218
- <div class="trace-head">
219
- <h2>Stage history</h2>
220
- <span class="muted">
221
- <% if history[:declaration] %>
222
- <%= history[:declaration].stages.join(" → ") %> · outcome funnel
223
- <% else %>
224
- no funnel declared — raw outcomes
225
- <% end %>
226
- </span>
227
- </div>
228
- <div class="kv">
229
- <% history[:rows].each do |row| %>
230
- <% if row[:stage] %>
231
- <span><%= row[:outcome] %></span>
232
- <div>→ <strong><%= row[:stage] %></strong> <span class="lat"><%= row[:at] %></span></div>
233
- <% else %>
234
- <span><%= row[:outcome] %></span>
235
- <div><span class="lat">not a declared stage</span> <span class="lat"><%= row[:at] %></span></div>
236
- <% end %>
237
- <% end %>
238
- </div>
239
- </div>
274
+ </details>
240
275
  <% end %>
241
276
  </aside>
242
277
  </div>