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}