diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ca4fdfd20b..95bc30e904 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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 = ({ onCancelQueueEdit, isEditingHistoryMessage = false, onCancelHistoryEdit, + onEditLastUserMessage, contextUsage, attachments = [], onAttach, @@ -774,11 +776,16 @@ export const AgentChatInput: FC = ({ 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 = ({ 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 = ({ } } }; + 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 = ({ 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 = ({ placeholder={placeholder} initialValue={initialValue} onChange={handleContentChange} + onKeyDown={handleEditorKeyDown} onEnter={handleSubmit} disabled={isDisabled || isLoading} autoFocus diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 7db618dc4c..fd08fdec97 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -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); }} > diff --git a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts index 762f3adf8d..8b3b43463a 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts @@ -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[] => { diff --git a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx index c75088c244..0225262870 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx @@ -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 = ({ onCancelQueueEdit, isEditingHistoryMessage, onCancelHistoryEdit, + onEditUserMessage, editingFileBlocks, mcpServers, selectedMCPServerIds, @@ -184,6 +191,24 @@ export const AgentDetailInput: FC = ({ 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 = ({ onCancelQueueEdit={onCancelQueueEdit} isEditingHistoryMessage={isEditingHistoryMessage} onCancelHistoryEdit={onCancelHistoryEdit} + onEditLastUserMessage={handleEditLastUserMessage} isDisabled={isInputDisabled} isLoading={isSendPending} isStreaming={isStreaming} diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index fd3ff693a4..7e605d2add 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -340,6 +340,7 @@ export const AgentDetailView: FC = ({ onCancelQueueEdit={editing.handleCancelQueueEdit} isEditingHistoryMessage={editing.editingMessageId !== null} onCancelHistoryEdit={editing.handleCancelHistoryEdit} + onEditUserMessage={editing.handleEditUserMessage} editingFileBlocks={editing.editingFileBlocks} mcpServers={mcpServers} selectedMCPServerIds={selectedMCPServerIds}