Module: Assistant::EditOpStreamer

Extended by:
IconHelper
Defined in:
app/services/assistant/edit_op_streamer.rb

Overview

Stage 9 of the Sunny blog editor fix plan: real-time per-op feedback in
the conversation transcript. Each edit_blog_post / remove_embed /
replace_embed / move_embed op appends a row to a "live edit log" frame
inside the assistant message bubble so the user can see ops apply as
they happen (and hit Stop earlier — now that Stop actually works, from
Stage 1 — if Sunny goes off-rails).

Built on Turbo::StreamsChannel.broadcast_append_to so each row is a
single ActionCable message, not a full re-render.

Constant Summary collapse

MAX_VISIBLE_OPS =

Maximum visible ops.

10

Constants included from IconHelper

IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY

Class Method Summary collapse

Methods included from IconHelper

account_nav_icon, fa_icon, star_rating_html

Class Method Details

.callback_for(conversation_id) ⇒ Object

Build an on_op Proc that can be passed to
BlockAddressedEditor#apply_ops (or invoked directly from the
atomic embed tools). If conversation_id is nil, returns a no-op
Proc so the editor still works outside of a chat context.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'app/services/assistant/edit_op_streamer.rb', line 31

def callback_for(conversation_id)
  return ->(_payload) {} unless conversation_id

  target = frame_target(conversation_id)
  stream = stream_name(conversation_id)
  counter = frame_state(conversation_id)
  counter[:seen] ||= 0

  lambda do |payload|
    ensure_frame(stream, target, counter)

    counter[:seen] += 1
    # Cap visible rows — after the cap, broadcast a single "+N more" row.
    broadcast_append(stream, target, overflow_html) if counter[:seen] == MAX_VISIBLE_OPS + 1
    next if counter[:seen] > MAX_VISIBLE_OPS

    broadcast_append(stream, target, op_row_html(payload))
  rescue StandardError => e
    Rails.logger.warn("[EditOpStreamer] broadcast failed: #{e.class}: #{e.message}")
  end
end

.emit_single(conversation_id:, op:, detail: nil, status: 'applied') ⇒ Object

Emit a single op event outside of a multi-op edit context
(used by remove_embed / replace_embed / move_embed — each is a
single "op" from the user's perspective).



56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/services/assistant/edit_op_streamer.rb', line 56

def emit_single(conversation_id:, op:, detail: nil, status: 'applied')
  return unless conversation_id

  target = frame_target(conversation_id)
  stream = stream_name(conversation_id)
  ensure_frame(stream, target, frame_state(conversation_id))

  payload = { op: op.to_s, status: status.to_s, detail: detail }
  broadcast_append(stream, target, op_row_html(payload))
rescue StandardError => e
  Rails.logger.warn("[EditOpStreamer] emit_single failed: #{e.class}: #{e.message}")
end

.frame_target(conversation_id) ⇒ Object



69
70
71
# File 'app/services/assistant/edit_op_streamer.rb', line 69

def frame_target(conversation_id)
  "edit-op-log-#{conversation_id}"
end

.reset_frame_states_for_test!Object

Test hook: clear the per-process frame-rendered cache so each test
starts from a clean slate.



79
80
81
# File 'app/services/assistant/edit_op_streamer.rb', line 79

def reset_frame_states_for_test!
  @frame_states_mutex.synchronize { @frame_states.clear }
end

.stream_name(conversation_id) ⇒ Object



73
74
75
# File 'app/services/assistant/edit_op_streamer.rb', line 73

def stream_name(conversation_id)
  "assistant_chat:#{conversation_id}"
end