mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user