fix: skip placeholder for DraftSender channels to prevent duplicate chat bubbles

When a channel supports DraftSender (e.g. Telegram private chats), both
a placeholder message ("Thinking...") and a streaming draft bubble were
created simultaneously. On final response, preSend edited the placeholder
but never called sendMessage, so the draft bubble lingered — causing the
user to see two near-identical messages until the draft auto-expired.

By skipping SendPlaceholder for DraftSender channels, the streaming draft
serves as the sole visual indicator. The final sendMessage then replaces
the draft with the permanent response in a single atomic transition.

https://claude.ai/code/session_01RPaJHXgB9DhsS9JZxWbTno
This commit is contained in:
Claude 2026-03-03 09:37:37 +00:00
parent 94c4aedf87
commit fcd6efe40b

View file

@ -284,13 +284,18 @@ func (c *BaseChannel) HandleMessage(
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
} }
} }
// Placeholder — independent pipeline // Placeholder — independent pipeline.
// Skip for DraftSender channels: the streaming draft bubble serves
// as the placeholder, and sendMessage on final response replaces it.
// Sending both a placeholder AND drafts causes duplicate chat bubbles.
if _, isDrafter := c.owner.(DraftSender); !isDrafter {
if pc, ok := c.owner.(PlaceholderCapable); ok { if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
} }
} }
} }
}
if err := c.bus.PublishInbound(ctx, msg); err != nil { if err := c.bus.PublishInbound(ctx, msg); err != nil {
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{