mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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.
|
// 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user