feat(site/src/pages/AgentsPage): add ArrowUp shortcut to edit last user message (#23705)

Add a keyboard shortcut (ArrowUp on empty input) to start editing the
most recent user message, mirroring Mux's behavior. The shortcut reuses
the existing history-edit flow triggered by the pencil button.

Extract a shared `getEditableUserMessagePayload` helper so the pencil
button and the new shortcut both derive the edit payload identically.
Derive the last editable user message during render in
`AgentDetailInput` from the existing store selectors, keeping the
implementation Effect-free and React Compiler friendly.
This commit is contained in:
Ethan
2026-03-27 21:43:31 +11:00
committed by GitHub
parent c4ef94aacf
commit 83b2f85d63
5 changed files with 78 additions and 22 deletions
@@ -137,6 +137,7 @@ interface AgentChatInputProps {
// History editing state, owned by the parent. // History editing state, owned by the parent.
isEditingHistoryMessage?: boolean; isEditingHistoryMessage?: boolean;
onCancelHistoryEdit?: () => void; onCancelHistoryEdit?: () => void;
onEditLastUserMessage?: () => void;
// Optional context-usage summary shown to the left of the send button. // Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown). // Pass `null` to render fallback values (e.g. when limit is unknown).
@@ -547,6 +548,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onCancelQueueEdit, onCancelQueueEdit,
isEditingHistoryMessage = false, isEditingHistoryMessage = false,
onCancelHistoryEdit, onCancelHistoryEdit,
onEditLastUserMessage,
contextUsage, contextUsage,
attachments = [], attachments = [],
onAttach, onAttach,
@@ -774,11 +776,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const hasUploadedAttachments = attachments.some( const hasUploadedAttachments = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploaded", (f) => uploadStates?.get(f)?.status === "uploaded",
); );
const hasDraftContext =
hasContent || attachments.length > 0 || hasFileReferences;
const isComposerEffectivelyEmpty = !hasDraftContext;
const hasSendableContent =
hasContent || hasUploadedAttachments || hasFileReferences;
const canSend = const canSend =
!isDisabled && !isDisabled &&
!isLoading && !isLoading &&
hasModelOptions && hasModelOptions &&
(hasContent || hasUploadedAttachments || hasFileReferences) && hasSendableContent &&
!isUploading; !isUploading;
const handleSubmit = () => { const handleSubmit = () => {
const text = internalRef.current?.getValue()?.trim() ?? ""; const text = internalRef.current?.getValue()?.trim() ?? "";
@@ -836,7 +843,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
setPreRecordingValue(""); setPreRecordingValue("");
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleComposerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (editingQueuedMessageID !== null) { if (editingQueuedMessageID !== null) {
e.preventDefault(); e.preventDefault();
@@ -850,6 +857,19 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
} }
} }
}; };
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
if (
e.key !== "ArrowUp" ||
editingQueuedMessageID !== null ||
isEditingHistoryMessage ||
!onEditLastUserMessage ||
!isComposerEffectivelyEmpty
) {
return;
}
e.preventDefault();
onEditLastUserMessage();
};
const sendButtonLabel = const sendButtonLabel =
editingQueuedMessageID !== null editingQueuedMessageID !== null
@@ -892,7 +912,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
isEditingHistoryMessage && isEditingHistoryMessage &&
"shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]", "shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
)} )}
onKeyDown={handleKeyDown} onKeyDown={handleComposerKeyDown}
onDragOver={onAttach ? handleDragOver : undefined} onDragOver={onAttach ? handleDragOver : undefined}
onDragLeave={onAttach ? handleDragLeave : undefined} onDragLeave={onAttach ? handleDragLeave : undefined}
onDrop={onAttach ? handleDrop : undefined} onDrop={onAttach ? handleDrop : undefined}
@@ -954,6 +974,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
placeholder={placeholder} placeholder={placeholder}
initialValue={initialValue} initialValue={initialValue}
onChange={handleContentChange} onChange={handleContentChange}
onKeyDown={handleEditorKeyDown}
onEnter={handleSubmit} onEnter={handleSubmit}
disabled={isDisabled || isLoading} disabled={isDisabled || isLoading}
autoFocus autoFocus
@@ -37,7 +37,10 @@ import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog"; import { TextPreviewDialog } from "../TextPreviewDialog";
import { ChatStatusCallout } from "./ChatStatusCallout"; import { ChatStatusCallout } from "./ChatStatusCallout";
import type { LiveStatusModel } from "./liveStatusModel"; import type { LiveStatusModel } from "./liveStatusModel";
import { buildSubagentTitles } from "./messageParsing"; import {
buildSubagentTitles,
getEditableUserMessagePayload,
} from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText"; import { useSmoothStreamingText } from "./SmoothText";
import type { import type {
MergedTool, MergedTool,
@@ -565,24 +568,9 @@ const ChatMessageItem = memo<{
className="mt-0.5 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-content-secondary opacity-0 transition-opacity hover:bg-surface-tertiary hover:text-content-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link group-hover/msg:opacity-100" className="mt-0.5 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-content-secondary opacity-0 transition-opacity hover:bg-surface-tertiary hover:text-content-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link group-hover/msg:opacity-100"
aria-label="Edit message" aria-label="Edit message"
onClick={() => { onClick={() => {
const fileBlocks = parsed.blocks.filter( const { text, fileBlocks } =
( getEditableUserMessagePayload(message);
b, onEditUserMessage(message.id, text, fileBlocks);
): b is Extract<
RenderBlock,
{ type: "file" }
> =>
b.type === "file" &&
(b.media_type.startsWith("image/") ||
b.media_type === "text/plain"),
);
onEditUserMessage(
message.id,
parsed.markdown || "",
fileBlocks.length > 0
? fileBlocks
: undefined,
);
}} }}
> >
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
@@ -224,6 +224,26 @@ export const parseMessageContent = (
return parsed; return parsed;
}; };
const isEditableUserMessageFileBlock = (
block: RenderBlock,
): block is TypesGen.ChatFilePart =>
block.type === "file" &&
(block.media_type.startsWith("image/") || block.media_type === "text/plain");
export const getEditableUserMessagePayload = (
message: TypesGen.ChatMessage,
): {
text: string;
fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined;
} => {
const parsed = parseMessageContent(message.content);
const fileBlocks = parsed.blocks.filter(isEditableUserMessageFileBlock);
return {
text: parsed.markdown || "",
fileBlocks: fileBlocks.length > 0 ? fileBlocks : undefined,
};
};
export const parseMessagesWithMergedTools = ( export const parseMessagesWithMergedTools = (
messages: readonly TypesGen.ChatMessage[], messages: readonly TypesGen.ChatMessage[],
): ParsedMessageEntry[] => { ): ParsedMessageEntry[] => {
@@ -26,6 +26,7 @@ import { LiveStreamTail } from "./AgentDetail/LiveStreamTail";
import { import {
buildComputerUseSubagentIds, buildComputerUseSubagentIds,
buildSubagentTitles, buildSubagentTitles,
getEditableUserMessagePayload,
parseMessagesWithMergedTools, parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing"; } from "./AgentDetail/messageParsing";
import { useOnRenderProfiler } from "./AgentDetail/useOnRenderProfiler"; import { useOnRenderProfiler } from "./AgentDetail/useOnRenderProfiler";
@@ -135,6 +136,11 @@ interface AgentDetailInputProps {
onCancelQueueEdit: () => void; onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean; isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void; onCancelHistoryEdit: () => void;
onEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
// File parts from the message being edited, converted to // File parts from the message being edited, converted to
// File objects and pre-populated into attachments. // File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[]; editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
@@ -169,6 +175,7 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
onCancelQueueEdit, onCancelQueueEdit,
isEditingHistoryMessage, isEditingHistoryMessage,
onCancelHistoryEdit, onCancelHistoryEdit,
onEditUserMessage,
editingFileBlocks, editingFileBlocks,
mcpServers, mcpServers,
selectedMCPServerIds, selectedMCPServerIds,
@@ -184,6 +191,24 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
const messages = orderedMessageIDs const messages = orderedMessageIDs
.map((messageID) => messagesByID.get(messageID)) .map((messageID) => messagesByID.get(messageID))
.filter(isChatMessage); .filter(isChatMessage);
let lastEditableUserMessage: TypesGen.ChatMessage | undefined;
for (let index = orderedMessageIDs.length - 1; index >= 0; index--) {
const message = messagesByID.get(orderedMessageIDs[index]);
if (message?.role === "user") {
lastEditableUserMessage = message;
break;
}
}
const handleEditLastUserMessage = lastEditableUserMessage
? () => {
const { text, fileBlocks } = getEditableUserMessagePayload(
lastEditableUserMessage,
);
onEditUserMessage(lastEditableUserMessage.id, text, fileBlocks);
}
: undefined;
const rawUsage = getLatestContextUsage(messages); const rawUsage = getLatestContextUsage(messages);
const latestContextUsage = rawUsage const latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold } ? { ...rawUsage, compressionThreshold }
@@ -297,6 +322,7 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
onCancelQueueEdit={onCancelQueueEdit} onCancelQueueEdit={onCancelQueueEdit}
isEditingHistoryMessage={isEditingHistoryMessage} isEditingHistoryMessage={isEditingHistoryMessage}
onCancelHistoryEdit={onCancelHistoryEdit} onCancelHistoryEdit={onCancelHistoryEdit}
onEditLastUserMessage={handleEditLastUserMessage}
isDisabled={isInputDisabled} isDisabled={isInputDisabled}
isLoading={isSendPending} isLoading={isSendPending}
isStreaming={isStreaming} isStreaming={isStreaming}
@@ -340,6 +340,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onCancelQueueEdit={editing.handleCancelQueueEdit} onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null} isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit} onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingFileBlocks={editing.editingFileBlocks} editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers} mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds} selectedMCPServerIds={selectedMCPServerIds}