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.
isEditingHistoryMessage?: boolean;
onCancelHistoryEdit?: () => void;
onEditLastUserMessage?: () => void;
// Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown).
@@ -547,6 +548,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onCancelQueueEdit,
isEditingHistoryMessage = false,
onCancelHistoryEdit,
onEditLastUserMessage,
contextUsage,
attachments = [],
onAttach,
@@ -774,11 +776,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const hasUploadedAttachments = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploaded",
);
const hasDraftContext =
hasContent || attachments.length > 0 || hasFileReferences;
const isComposerEffectivelyEmpty = !hasDraftContext;
const hasSendableContent =
hasContent || hasUploadedAttachments || hasFileReferences;
const canSend =
!isDisabled &&
!isLoading &&
hasModelOptions &&
(hasContent || hasUploadedAttachments || hasFileReferences) &&
hasSendableContent &&
!isUploading;
const handleSubmit = () => {
const text = internalRef.current?.getValue()?.trim() ?? "";
@@ -836,7 +843,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
setPreRecordingValue("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const handleComposerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (editingQueuedMessageID !== null) {
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 =
editingQueuedMessageID !== null
@@ -892,7 +912,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
isEditingHistoryMessage &&
"shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
)}
onKeyDown={handleKeyDown}
onKeyDown={handleComposerKeyDown}
onDragOver={onAttach ? handleDragOver : undefined}
onDragLeave={onAttach ? handleDragLeave : undefined}
onDrop={onAttach ? handleDrop : undefined}
@@ -954,6 +974,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
placeholder={placeholder}
initialValue={initialValue}
onChange={handleContentChange}
onKeyDown={handleEditorKeyDown}
onEnter={handleSubmit}
disabled={isDisabled || isLoading}
autoFocus
@@ -37,7 +37,10 @@ import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import { ChatStatusCallout } from "./ChatStatusCallout";
import type { LiveStatusModel } from "./liveStatusModel";
import { buildSubagentTitles } from "./messageParsing";
import {
buildSubagentTitles,
getEditableUserMessagePayload,
} from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
import type {
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"
aria-label="Edit message"
onClick={() => {
const fileBlocks = parsed.blocks.filter(
(
b,
): 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,
);
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon className="size-3.5" />
@@ -224,6 +224,26 @@ export const parseMessageContent = (
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 = (
messages: readonly TypesGen.ChatMessage[],
): ParsedMessageEntry[] => {
@@ -26,6 +26,7 @@ import { LiveStreamTail } from "./AgentDetail/LiveStreamTail";
import {
buildComputerUseSubagentIds,
buildSubagentTitles,
getEditableUserMessagePayload,
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { useOnRenderProfiler } from "./AgentDetail/useOnRenderProfiler";
@@ -135,6 +136,11 @@ interface AgentDetailInputProps {
onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void;
onEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
@@ -169,6 +175,7 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
onCancelQueueEdit,
isEditingHistoryMessage,
onCancelHistoryEdit,
onEditUserMessage,
editingFileBlocks,
mcpServers,
selectedMCPServerIds,
@@ -184,6 +191,24 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
const messages = orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
.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 latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold }
@@ -297,6 +322,7 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
onCancelQueueEdit={onCancelQueueEdit}
isEditingHistoryMessage={isEditingHistoryMessage}
onCancelHistoryEdit={onCancelHistoryEdit}
onEditLastUserMessage={handleEditLastUserMessage}
isDisabled={isInputDisabled}
isLoading={isSendPending}
isStreaming={isStreaming}
@@ -340,6 +340,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}