Module: Assistant::EditOpStreamer

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 =
10

Class Method Summary collapse

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.



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

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.
    if counter[:seen] == MAX_VISIBLE_OPS + 1
      broadcast_append(stream, target, overflow_html)
    end
    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).



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

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



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

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.



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

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

.stream_name(conversation_id) ⇒ Object



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

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