Skip to content

Add message retry and regenerate functionality #18

@anurag629

Description

@anurag629

Summary

Add the ability for users to retry a failed message or regenerate the last assistant response. Currently, when a message fails (network error, rate limit, provider error) or the AI gives a poor response, users have no recourse — they're stuck. This is a fundamental UX gap.

Current Behavior

When an error occurs in packages/widget/src/widget.ts (handleSend, lines 253-305):

  1. Error messages are shown as assistant messages (e.g., "Something went wrong. Please try again.")
  2. The error message is stored in this.messages and saved to localStorage
  3. There is no way to retry — the user can only type a new message
  4. There is no way to regenerate — if the AI response is poor, the user must rephrase

Expected Behavior

Retry (on error)

  • When a message fails, show a retry button on the error message bubble
  • Clicking retry re-sends the same user message to the server
  • The error message is replaced with a new streaming response
  • The typing indicator appears again during retry

Regenerate (on success)

  • Each assistant message shows a regenerate button on hover (only on the last assistant message)
  • Clicking regenerate removes the last assistant response, re-sends the conversation to the server
  • The server generates a new response for the same user message
  • Streaming resumes with the new response replacing the old one

Implementation Guide

Phase 1: Update Message Data Model

File: packages/widget/src/dom/messages.ts

Add status tracking and action buttons to messages:

export interface MessageData {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  status?: 'streaming' | 'complete' | 'error';  // NEW
  errorType?: string;  // NEW — 'rate_limit' | 'network' | 'provider_error' | 'timeout'
}

Phase 2: Add Action Buttons to Message Bubbles

File: packages/widget/src/dom/messages.ts

Modify addMessage() and add a new method to render action buttons:

export interface MessageActions {
  onRetry?: (messageId: string) => void;
  onRegenerate?: (messageId: string) => void;
}

export class Messages {
  private actions?: MessageActions;

  constructor(parent: HTMLElement, actions?: MessageActions) {
    // ... existing code ...
    this.actions = actions;
  }

  addMessage(msg: MessageData): HTMLDivElement {
    // ... existing message rendering ...

    // Add retry button for error messages
    if (msg.status === 'error' && this.actions?.onRetry) {
      const retryBtn = document.createElement('button');
      retryBtn.className = 'cc-message-retry';
      retryBtn.innerHTML = `
        <svg viewBox="0 0 24 24" width="14" height="14">
          <path d="M17.65 6.35A7.96 7.96 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
        </svg>
        Retry
      `;
      retryBtn.addEventListener('click', () => this.actions!.onRetry!(msg.id));
      el.appendChild(retryBtn);
    }

    return el;
  }

  // Show regenerate button on the last assistant message
  showRegenerateButton(messageId: string): void {
    // Remove any existing regenerate buttons first
    this.container.querySelectorAll('.cc-message-regenerate').forEach(btn => btn.remove());

    const msgEl = this.container.querySelector(`[data-id="${messageId}"]`);
    if (!msgEl || !this.actions?.onRegenerate) return;

    const regenBtn = document.createElement('button');
    regenBtn.className = 'cc-message-regenerate';
    regenBtn.innerHTML = `
      <svg viewBox="0 0 24 24" width="14" height="14">
        <path d="M17.65 6.35A7.96 7.96 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
      </svg>
      Regenerate
    `;
    regenBtn.addEventListener('click', () => this.actions!.onRegenerate!(messageId));
    msgEl.appendChild(regenBtn);
  }

  // Remove a message by ID (for regenerate flow)
  removeMessage(id: string): void {
    const el = this.container.querySelector(`[data-id="${id}"]`);
    if (el) el.remove();
  }
}

Phase 3: Widget Retry/Regenerate Logic

File: packages/widget/src/widget.ts

Update the Widget class:

A. Pass action handlers to Messages (via Panel)

Update the Panel constructor call (line 113-125) to pass message actions:

this.panel = new Panel(this.shadow, {
  // ... existing options ...
  messageActions: {
    onRetry: (msgId) => this.handleRetry(msgId),
    onRegenerate: (msgId) => this.handleRegenerate(msgId),
  },
});

B. Track message status

Update handleSend() (lines 226-306):

  • Set status: 'streaming' when creating the assistant message
  • Set status: 'complete' on successful completion
  • Set status: 'error' on error, with errorType
  • After successful completion, call this.panel.messages.showRegenerateButton(assistantMsg.id)
// In the error handler (around line 261-270):
if (chunk.error) {
  assistantMsg.content = this.locale[errorKey];
  assistantMsg.status = 'error';
  assistantMsg.errorType = chunk.error;
  this.panel.messages.removeTyping();
  this.panel.addMessage(assistantMsg);  // will render with retry button
  break;
}

// After successful stream completion (around line 286-294):
if (assistantMsg.content) {
  assistantMsg.status = 'complete';
  this.messages.push(assistantMsg);
  this.panel.messages.showRegenerateButton(assistantMsg.id);
  // ... existing event emission ...
}

C. Add retry handler

private async handleRetry(errorMessageId: string): Promise<void> {
  if (this.isStreaming) return;

  // Find the error message and the user message before it
  const errorIndex = this.messages.findIndex(m => m.id === errorMessageId);
  if (errorIndex === -1) return;

  // Find the last user message before this error
  let userMessage: MessageData | undefined;
  for (let i = errorIndex - 1; i >= 0; i--) {
    if (this.messages[i].role === 'user') {
      userMessage = this.messages[i];
      break;
    }
  }
  if (!userMessage) return;

  // Remove the error message from DOM and data
  this.panel.messages.removeMessage(errorMessageId);
  this.messages = this.messages.filter(m => m.id !== errorMessageId);

  // Re-send the same user message (reuse handleSendInternal)
  await this.sendToServer(userMessage.content);
}

D. Add regenerate handler

private async handleRegenerate(assistantMessageId: string): Promise<void> {
  if (this.isStreaming) return;

  // Find the assistant message
  const assistantIndex = this.messages.findIndex(m => m.id === assistantMessageId);
  if (assistantIndex === -1) return;

  // Find the user message that triggered this response
  let userMessage: MessageData | undefined;
  for (let i = assistantIndex - 1; i >= 0; i--) {
    if (this.messages[i].role === 'user') {
      userMessage = this.messages[i];
      break;
    }
  }
  if (!userMessage) return;

  // Remove the assistant message from DOM and data
  this.panel.messages.removeMessage(assistantMessageId);
  this.messages = this.messages.filter(m => m.id !== assistantMessageId);

  // Re-send to get a new response
  await this.sendToServer(userMessage.content);
}

E. Extract sendToServer() from handleSend()

Refactor handleSend() (lines 226-306) to extract the streaming logic into a reusable sendToServer(text: string) method. handleSend() adds the user message to the DOM, then calls sendToServer(). handleRetry() and handleRegenerate() call sendToServer() directly (the user message is already in the DOM/data).

Phase 4: Server-Side Regenerate Support

The server already supports regeneration implicitly — it uses the full conversation history. But for regenerate, the client needs to:

  1. Remove the last assistant message from the conversation on the client
  2. Re-send with the same conversationId
  3. The server sees the conversation without the removed response and generates a new one

File: packages/server/src/handler.ts

No server changes needed if the widget simply doesn't include the removed message in subsequent context. However, since conversation history is managed server-side via ConversationManager, add a delete-last-message capability:

// Add to ConversationManager or expose via handler
async function handleRegenerate(conversationId: string): Promise<void> {
  // Remove the last assistant message from server-side conversation
  const messages = await conversations.getMessages(conversationId);
  const lastAssistantIdx = [...messages].reverse().findIndex(m => m.role === 'assistant');
  if (lastAssistantIdx !== -1) {
    // Remove it so the next chat() call doesn't include it
    await conversations.removeLastMessage(conversationId, 'assistant');
  }
}

File: packages/widget/src/api/types.ts

Add a regenerate flag to WidgetChatRequest:

export interface WidgetChatRequest {
  conversationId: string;
  message: string;
  regenerate?: boolean;  // NEW — signals the server to drop the last assistant message
  // ... existing fields ...
}

File: packages/server/src/handler.ts

Handle regenerate flag before generating response:

if (req.regenerate) {
  // Remove the last assistant message from conversation history
  const messages = await conversations.getMessages(req.conversationId);
  const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
  if (lastAssistant) {
    await conversations.removeMessage(req.conversationId, lastAssistant.id);
  }
}

File: packages/core/src/conversation/manager.ts

Add removeMessage() method:

async removeMessage(conversationId: string, messageId: string): Promise<void> {
  const conversation = this.conversations.get(conversationId);
  if (conversation) {
    conversation.messages = conversation.messages.filter(m => m.id !== messageId);
  }
}

Phase 5: Styling

File: packages/widget/src/styles/widget.css

/* Message action buttons */
.cc-message-retry,
.cc-message-regenerate {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-top: 8px;
  padding: 4px 10px;
  background: transparent;
  border: 1px solid var(--cc-border);
  border-radius: 6px;
  color: var(--cc-text-secondary);
  font-family: var(--cc-font);
  font-size: 12px;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}

.cc-message-retry:hover,
.cc-message-regenerate:hover {
  background: var(--cc-bg-input);
  color: var(--cc-text);
}

.cc-message-retry svg,
.cc-message-regenerate svg {
  fill: currentColor;
}

/* Regenerate only shows on hover of the last assistant message */
.cc-message-regenerate {
  opacity: 0;
  transition: opacity 0.15s ease;
}

.cc-message:hover .cc-message-regenerate {
  opacity: 1;
}

/* Retry button is always visible (error state) */
.cc-message-retry {
  border-color: #ef4444;
  color: #ef4444;
}

.cc-message-retry:hover {
  background: rgba(239, 68, 68, 0.1);
  color: #ef4444;
}

Phase 6: i18n

File: packages/widget/src/i18n.ts

Add locale strings:

export interface WidgetLocaleStrings {
  // ... existing ...
  retryButton: string;        // "Retry"
  regenerateButton: string;   // "Regenerate"
}

const en: WidgetLocaleStrings = {
  // ... existing ...
  retryButton: 'Retry',
  regenerateButton: 'Regenerate',
};

Files to Modify

File Change
packages/widget/src/dom/messages.ts Add status/errorType to MessageData, retry/regenerate buttons, removeMessage(), showRegenerateButton()
packages/widget/src/dom/panel.ts Pass MessageActions through to Messages constructor
packages/widget/src/widget.ts Add handleRetry(), handleRegenerate(), extract sendToServer(), track message status
packages/widget/src/api/types.ts Add regenerate flag to WidgetChatRequest
packages/widget/src/styles/widget.css Retry/regenerate button styles
packages/widget/src/i18n.ts Add retryButton, regenerateButton strings
packages/server/src/handler.ts Handle regenerate flag, remove last assistant message
packages/server/src/config.ts Add regenerate to Zod schema
packages/core/src/conversation/manager.ts Add removeMessage() method

Tests to Add

  • Unit: Messages.addMessage() renders retry button when status: 'error'
  • Unit: Messages.addMessage() does NOT render retry button when status: 'complete'
  • Unit: Messages.removeMessage() removes the element from DOM
  • Unit: Messages.showRegenerateButton() adds button to the specified message
  • Unit: Widget.handleRetry() removes error message and re-sends the same user message
  • Unit: Widget.handleRegenerate() removes assistant message and re-sends
  • Unit: Retry/regenerate is blocked when isStreaming is true
  • Unit: ConversationManager.removeMessage() removes the correct message
  • Unit: Server handler removes last assistant message when regenerate: true
  • Integration: Full retry flow — send → error → retry → success

Acceptance Criteria

  • Failed messages show a visible "Retry" button
  • Clicking "Retry" re-sends the same user message and replaces the error with a new response
  • Successful assistant messages show a "Regenerate" button on hover (last message only)
  • Clicking "Regenerate" removes the old response and streams a new one
  • Retry and regenerate are disabled during active streaming
  • Server-side conversation history is correctly updated (old response removed before regenerating)
  • Retry/regenerate buttons are styled consistently with the dark theme
  • History persistence works correctly after retry/regenerate (saved messages reflect the final state)
  • i18n strings for button labels are configurable
  • All existing tests pass, new tests cover retry/regenerate flows
  • No regressions in normal send flow

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or capabilitypriority: highHigh priority itemserverAffects @chatcops/server packagewidgetAffects @chatcops/widget package

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions