From 120c80239d5ecfdc4b26cbdfa855b93a20f83b70 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 12 Jul 2026 21:15:38 +0100 Subject: [PATCH] feat(assistant): edit-and-resend pencil on the latest user prompt The undo flow already restores the removed prompt into the composer for editing, but the only entry points were the header undo button and /undo. Surface it where users look for it: a hover pencil on the latest non-queued user prompt that undoes the turn and prefills the composer, riding the same last-turn undo boundary as regenerate. --- .../src/components/AI/Chat/ChatMessages.tsx | 20 ++++++++++++++ .../src/components/AI/Chat/MessageItem.tsx | 15 +++++++++++ .../AI/Chat/__tests__/ChatMessages.test.tsx | 27 +++++++++++++++++++ .../src/components/AI/Chat/index.tsx | 1 + 4 files changed, 63 insertions(+) diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index 1e22afed0..e40d7a813 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -29,6 +29,7 @@ interface ChatMessagesProps { onSkipQuestion: (messageId: string, questionId: string) => void; onRetry?: (messageId: string) => void; onRegenerate?: (messageId: string) => void; + onEditPrompt?: (messageId: string) => void; onChangeModel?: () => void; getModelRouteLabel?: (modelId: string) => string; getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null; @@ -86,6 +87,20 @@ export const ChatMessages: Component = (props) => { return hasPrompt ? last.id : null; }); + // Edit-and-resend rides the same last-turn undo boundary: the only editable + // prompt is the user message that undo would remove, i.e. the latest + // non-queued user prompt. + const editablePromptMessageId = createMemo(() => { + if (!props.onEditPrompt) return null; + const msgs = props.messages; + for (let i = msgs.length - 1; i >= 0; i--) { + const msg = msgs[i]; + if (msg.role !== 'user' || msg.delivery === 'queued') continue; + return msg.content.trim() ? msg.id : null; + } + return null; + }); + const isContainerNearBottom = () => { if (!containerRef) return true; const { scrollTop, scrollHeight, clientHeight } = containerRef; @@ -306,6 +321,11 @@ export const ChatMessages: Component = (props) => { ? () => props.onRegenerate?.(message.id) : undefined } + onEditPrompt={ + message.id === editablePromptMessageId() + ? () => props.onEditPrompt?.(message.id) + : undefined + } onChangeModel={props.onChangeModel} getModelRouteLabel={props.getModelRouteLabel} modelRouteAlternative={props.getModelRouteAlternative?.(message)} diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 49fd5d037..04fc3ffa1 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -58,6 +58,9 @@ interface MessageItemProps { // Provided only for the latest settled assistant answer: re-runs the turn in // place (same prompt, fresh generation). onRegenerate?: () => void; + // Provided only for the user prompt of the latest turn: undoes the turn and + // restores the prompt into the composer for editing. + onEditPrompt?: () => void; onChangeModel?: () => void; getModelRouteLabel?: (modelId: string) => string; modelRouteAlternative?: ModelRouteRecoveryOption | null; @@ -543,6 +546,18 @@ export const MessageItem: Component = (props) => { {/* User message - compact bubble */}
+ + props.onEditPrompt?.()} + label="Edit and resend" + title="Edit and resend" + tone="outline" + size="sm" + class="mt-1 opacity-0 shadow-sm transition-opacity focus:opacity-100 group-hover:opacity-100" + > + + void; onSkipQuestion: (questionId: string) => void; onRegenerate?: () => void; + onEditPrompt?: () => void; onChangeModel?: () => void; getModelRouteLabel?: (modelId: string) => string; modelRouteAlternative?: ModelRouteRecoveryOption | null; @@ -43,6 +44,7 @@ vi.mock('../MessageItem', () => ({ ) => void; onSkipQuestion: (questionId: string) => void; onRegenerate?: () => void; + onEditPrompt?: () => void; onChangeModel?: () => void; getModelRouteLabel?: (modelId: string) => string; modelRouteAlternative?: ModelRouteRecoveryOption | null; @@ -431,6 +433,31 @@ describe('ChatMessages', () => { } }); + it('provides onEditPrompt only for the latest non-queued user prompt', () => { + const handlers = makeHandlers(); + const onEditPrompt = vi.fn(); + const messages = [ + makeMessage({ id: 'u1', role: 'user', content: 'first prompt' }), + makeMessage({ id: 'a1', role: 'assistant', content: 'first answer' }), + makeMessage({ id: 'u2', role: 'user', content: 'second prompt' }), + makeMessage({ id: 'a2', role: 'assistant', content: 'second answer' }), + makeMessage({ id: 'q1', role: 'user', content: 'queued follow-up', delivery: 'queued' }), + ]; + render(() => ( + + )); + + const earlier = capturedMessageItemProps.find((p) => p.message.id === 'u1'); + const latest = capturedMessageItemProps.find((p) => p.message.id === 'u2'); + const queued = capturedMessageItemProps.find((p) => p.message.id === 'q1'); + expect(earlier!.onEditPrompt).toBeUndefined(); + expect(queued!.onEditPrompt).toBeUndefined(); + expect(latest!.onEditPrompt).toBeDefined(); + + latest!.onEditPrompt!(); + expect(onEditPrompt).toHaveBeenCalledWith('u2'); + }); + it('forwards onSkip with message.id prepended', () => { const handlers = makeHandlers(); render(() => ); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index a40045d92..a169aa974 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -4837,6 +4837,7 @@ export const AIChat: Component = (props) => { onRegenerate={ chat.isLoading() ? undefined : (messageId) => void chat.retryMessage(messageId) } + onEditPrompt={canUndoLastTurn() ? () => void handleUndoLastTurn() : undefined} onChangeModel={openModelSelectorFromError} getModelRouteLabel={formatChatMessageModelRoute} getModelRouteAlternative={getFailedTurnModelRouteAlternative}