mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user