feat: wire retry and edit buttons on user messages

- Add retryMessage(messageId) to useChat — truncates from the target
  message onward and resends the preceding user message. Works for
  both user and assistant messages.
- Add editMessage(messageId) to useChat — truncates from the target
  user message onward and populates the chat input draft with the
  original text for revision.
- Wire onRetryMessage and onEditMessage from ChatView through
  MessageTimeline to MessageBubble.
- Remove role gate so retry icon shows on both user and assistant
  messages (edit remains user-only).
This commit is contained in:
Taylor Ho
2026-04-13 17:53:33 -10:00
parent bd14186214
commit e5dc7095fd
7 changed files with 164 additions and 23 deletions
+86 -18
View File
@@ -326,34 +326,98 @@ export function useChat(
});
}, [getStreamingPersonaId, store, sessionId]);
/** Retry from a specific message — truncates everything from that message
* onward and re-sends the preceding user message. Works for both user
* messages (re-send that message) and assistant messages (re-send the
* user message that triggered it). */
const retryMessage = useCallback(
async (messageId: string) => {
// Guard: don't truncate while the agent is still responding
if (chatState === "streaming" || chatState === "thinking") return;
const sessionMessages = store.messagesBySession[sessionId] ?? [];
const targetIndex = sessionMessages.findIndex(
(m) => m.id === messageId,
);
if (targetIndex === -1) return;
const targetMessage = sessionMessages[targetIndex];
// Determine which user message to re-send
let userMessage: (typeof sessionMessages)[number] | undefined;
let truncateFromIndex: number;
if (targetMessage.role === "user") {
userMessage = targetMessage;
truncateFromIndex = targetIndex;
} else {
// Find the preceding user message
const userIndex = findLastIndex(
sessionMessages.slice(0, targetIndex + 1),
(m) => m.role === "user",
);
if (userIndex === -1) return;
userMessage = sessionMessages[userIndex];
truncateFromIndex = userIndex;
}
// Truncate from the user message onward (removes user msg + all responses)
store.setMessages(sessionId, sessionMessages.slice(0, truncateFromIndex));
const textContent = userMessage.content.find((c) => c.type === "text");
if (textContent && "text" in textContent) {
const targetPersonaId = userMessage.metadata?.targetPersonaId;
const targetPersonaName = userMessage.metadata?.targetPersonaName;
await sendMessage(
textContent.text,
targetPersonaId
? { id: targetPersonaId, name: targetPersonaName }
: undefined,
);
}
},
[sessionId, store, sendMessage, chatState],
);
const retryLastMessage = useCallback(async () => {
const sessionMessages = store.messagesBySession[sessionId] ?? [];
// Find the last user message
const lastUserIndex = findLastIndex(
sessionMessages,
(m) => m.role === "user",
);
if (lastUserIndex === -1) return;
await retryMessage(sessionMessages[lastUserIndex].id);
}, [sessionId, store, retryMessage]);
const lastUserMessage = sessionMessages[lastUserIndex];
/** Enter edit mode for a user message — non-destructive. Populates the
* input draft with the original text and sets editing state. Truncation
* happens only when the user actually sends (handled in ChatView). */
const editMessage = useCallback(
(messageId: string) => {
// Guard: don't enter edit mode while the agent is still responding
if (chatState === "streaming" || chatState === "thinking") return;
// Remove all messages after (and including) the last assistant response
const messagesToKeep = sessionMessages.slice(0, lastUserIndex);
store.setMessages(sessionId, messagesToKeep);
const sessionMessages = store.messagesBySession[sessionId] ?? [];
const target = sessionMessages.find((m) => m.id === messageId);
if (!target || target.role !== "user") return;
// Extract the text and resend
const textContent = lastUserMessage.content.find((c) => c.type === "text");
if (textContent && "text" in textContent) {
const targetPersonaId = lastUserMessage.metadata?.targetPersonaId;
const targetPersonaName = lastUserMessage.metadata?.targetPersonaName;
await sendMessage(
textContent.text,
targetPersonaId
? { id: targetPersonaId, name: targetPersonaName }
: undefined,
);
}
}, [sessionId, store, sendMessage]);
// Extract the original text to pre-fill the input
const textContent = target.content.find((c) => c.type === "text");
const originalText =
textContent && "text" in textContent ? textContent.text : "";
// Enter edit mode — history stays intact until send
store.setEditingMessageId(sessionId, messageId);
store.setDraft(sessionId, originalText);
},
[sessionId, store, chatState],
);
/** Cancel edit mode — clears editing state and draft. */
const cancelEdit = useCallback(() => {
store.setEditingMessageId(sessionId, null);
store.clearDraft(sessionId);
}, [sessionId, store]);
const clearChat = useCallback(() => {
abortRef.current?.abort();
@@ -375,6 +439,10 @@ export function useChat(
stopGeneration,
stopStreaming,
retryLastMessage,
retryMessage,
editMessage,
cancelEdit,
editingMessageId: store.editingMessageIdBySession[sessionId] ?? null,
clearChat,
isStreaming,
};
@@ -72,6 +72,8 @@ interface ChatStoreState {
isConnected: boolean;
loadingSessionIds: Set<string>;
scrollTargetMessageBySession: Record<string, ScrollTargetMessage | null>;
/** Message ID currently being edited per session (non-destructive edit mode). */
editingMessageIdBySession: Record<string, string | null>;
}
interface ChatStoreActions {
@@ -108,6 +110,7 @@ interface ChatStoreActions {
dismissQueuedMessage: (sessionId: string) => void;
setDraft: (sessionId: string, text: string) => void;
clearDraft: (sessionId: string) => void;
setEditingMessageId: (sessionId: string, messageId: string | null) => void;
setSessionLoading: (sessionId: string, loading: boolean) => void;
setScrollTargetMessage: (
sessionId: string,
@@ -126,6 +129,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
sessionStateById: {},
queuedMessageBySession: {},
draftsBySession: loadCachedDrafts(),
editingMessageIdBySession: {},
activeSessionId: null,
isConnected: false,
loadingSessionIds: new Set<string>(),
@@ -430,6 +434,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
persistDrafts(get().draftsBySession);
},
setEditingMessageId: (sessionId, messageId) => {
set((state) => ({
editingMessageIdBySession: {
...state.editingMessageIdBySession,
[sessionId]: messageId,
},
}));
},
// Session loading (replay)
setSessionLoading: (sessionId, loading) =>
set((state) => {
@@ -477,12 +490,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const { [sessionId]: ____, ...remainingDrafts } = state.draftsBySession;
const { [sessionId]: _____, ...remainingTargets } =
state.scrollTargetMessageBySession;
const { [sessionId]: ______, ...remainingEditing } =
state.editingMessageIdBySession;
return {
messagesBySession: rest,
sessionStateById: remainingSessionState,
queuedMessageBySession: remainingQueued,
draftsBySession: remainingDrafts,
scrollTargetMessageBySession: remainingTargets,
editingMessageIdBySession: remainingEditing,
activeSessionId:
state.activeSessionId === sessionId ? null : state.activeSessionId,
};
+24 -1
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { X } from "lucide-react";
import { Pencil, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AcpProvider } from "@/shared/api/acp";
import type { Persona } from "@/shared/types/agents";
@@ -64,6 +64,10 @@ interface ChatInputProps {
}) => void;
contextTokens?: number;
contextLimit?: number;
/** Message ID currently being edited (non-null = edit mode active). */
editingMessageId?: string | null;
/** Cancel edit mode — clears editing state and restores input. */
onCancelEdit?: () => void;
}
export function ChatInput({
@@ -94,6 +98,8 @@ export function ChatInput({
onCreateProject,
contextTokens = 0,
contextLimit = 0,
editingMessageId = null,
onCancelEdit,
}: ChatInputProps) {
const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue);
@@ -395,6 +401,23 @@ export function ChatInput({
</div>
)}
{editingMessageId && (
<div className="mb-2 flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-1.5">
<Pencil className="size-3 shrink-0 text-brand" />
<span className="flex-1 truncate text-xs text-brand">
{t("edit.label")}
</span>
<button
type="button"
onClick={onCancelEdit}
className="shrink-0 rounded p-0.5 text-brand/60 hover:text-brand"
aria-label={t("edit.cancel")}
>
<X className="size-3.5" />
</button>
</div>
)}
<PopoverAnchor asChild>
<textarea
ref={textareaRef}
@@ -337,6 +337,10 @@ export function ChatView({
tokenState,
sendMessage,
stopStreaming,
retryMessage,
editMessage,
cancelEdit,
editingMessageId,
streamingMessageId,
} = useChat(
activeSessionId,
@@ -382,6 +386,28 @@ export function ChatView({
deferredSend.current = { text, attachments };
return;
}
// Edit mode: truncate from the edited message onward, then send.
// Must run before the queue check — edited sends are never queued.
if (editingMessageId) {
// Stop any active generation so the truncated history is clean
if (chatState !== "idle") {
stopStreaming();
}
const allMessages =
useChatStore.getState().messagesBySession[activeSessionId] ?? [];
const editIndex = allMessages.findIndex(
(m) => m.id === editingMessageId,
);
if (editIndex !== -1) {
useChatStore
.getState()
.setMessages(activeSessionId, allMessages.slice(0, editIndex));
}
useChatStore.getState().setEditingMessageId(activeSessionId, null);
sendMessage(text, undefined, attachments);
return;
}
// Queue if agent is busy and no message already queued
if (chatState !== "idle" && !queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
@@ -399,6 +425,8 @@ export function ChatView({
activeSessionId,
chatState,
queue,
editingMessageId,
stopStreaming,
],
);
@@ -465,6 +493,8 @@ export function ChatView({
scrollTargetMessageId={scrollTarget?.messageId ?? null}
scrollTargetQuery={scrollTarget?.query ?? null}
onScrollTargetHandled={handleScrollTargetHandled}
onRetryMessage={retryMessage}
onEditMessage={editMessage}
/>
)}
@@ -517,6 +547,8 @@ export function ChatView({
}
contextTokens={tokenState.accumulatedTotal}
contextLimit={tokenState.contextLimit}
editingMessageId={editingMessageId}
onCancelEdit={cancelEdit}
/>
</div>
@@ -450,7 +450,7 @@ export const MessageBubble = memo(function MessageBubble({
{/* Hover actions + timestamp */}
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
{textContent && <CopyAction text={textContent} />}
{!isUser && onRetryMessage && (
{onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
@@ -208,9 +208,7 @@ export function MessageTimeline({
<MessageBubble
message={message}
isStreaming={message.id === streamingMessageId}
onRetryMessage={
message.role === "assistant" ? onRetryMessage : undefined
}
onRetryMessage={onRetryMessage}
onEditMessage={
message.role === "user" ? onEditMessage : undefined
}
@@ -134,6 +134,10 @@
"dismiss": "Dismiss queued message",
"label": "Queued: {{text}}"
},
"edit": {
"label": "Editing message",
"cancel": "Cancel edit"
},
"skeleton": {
"loadingConversation": "Loading conversation"
},