-
Notifications
You must be signed in to change notification settings - Fork 3
Add message retry and regenerate functionality #18
Description
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):
- Error messages are shown as assistant messages (e.g., "Something went wrong. Please try again.")
- The error message is stored in
this.messagesand saved to localStorage - There is no way to retry — the user can only type a new message
- 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, witherrorType - 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:
- Remove the last assistant message from the conversation on the client
- Re-send with the same
conversationId - 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 whenstatus: 'error' - Unit:
Messages.addMessage()does NOT render retry button whenstatus: '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
isStreamingis 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