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.
This commit is contained in:
rcourtman
2026-07-12 21:15:38 +01:00
parent 6d1dced727
commit 120c80239d
4 changed files with 63 additions and 0 deletions
@@ -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<ChatMessagesProps> = (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<ChatMessagesProps> = (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)}
@@ -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<MessageItemProps> = (props) => {
{/* User message - compact bubble */}
<Show when={isUser()}>
<div class="group flex max-w-[85%] items-start justify-end gap-2">
<Show when={props.onEditPrompt}>
<ActionIconButton
onClick={() => 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"
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={canCopy()}>
<CopyValueButton
value={copyableMessageText()}
@@ -21,6 +21,7 @@ let capturedMessageItemProps: Array<{
) => 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(() => (
<ChatMessages messages={messages} {...handlers} onEditPrompt={onEditPrompt} />
));
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(() => <ChatMessages messages={[makeMessage({ id: 'msg-99' })]} {...handlers} />);
@@ -4837,6 +4837,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
onRegenerate={
chat.isLoading() ? undefined : (messageId) => void chat.retryMessage(messageId)
}
onEditPrompt={canUndoLastTurn() ? () => void handleUndoLastTurn() : undefined}
onChangeModel={openModelSelectorFromError}
getModelRouteLabel={formatChatMessageModelRoute}
getModelRouteAlternative={getFailedTurnModelRouteAlternative}