diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1821c65ed9..e6984aed8e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,10 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + ProjectId, + type MessageId, + type ModelSelection, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; @@ -11,6 +17,7 @@ import { } from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +export const EDIT_REVERT_SYNC_TIMEOUT_MS = 3000; const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -119,6 +126,80 @@ export function cloneComposerImageForRetry( } } +export function waitForThreadMessageRemoval( + threadId: ThreadId, + messageId: MessageId, + timeoutMs = EDIT_REVERT_SYNC_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + const hasMessage = () => + useStore + .getState() + .threads.find((thread) => thread.id === threadId) + ?.messages.some((message) => message.id === messageId) ?? false; + + if (!hasMessage()) { + resolve(); + return; + } + + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + globalThis.clearTimeout(timeoutId); + resolve(); + }; + + const unsubscribe = useStore.subscribe((state) => { + const stillPresent = + state.threads + .find((thread) => thread.id === threadId) + ?.messages.some((message) => message.id === messageId) ?? false; + if (!stillPresent) { + finish(); + } + }); + + const timeoutId = globalThis.setTimeout(() => { + finish(); + }, timeoutMs); + }); +} + +export async function materializeMessageImageAttachmentForEdit( + attachment: Extract[number], { type: "image" }>, +): Promise { + if (!attachment.previewUrl) { + return null; + } + + try { + const response = await fetch(attachment.previewUrl); + if (!response.ok) { + throw new Error(`Failed to load ${attachment.name}.`); + } + const blob = await response.blob(); + const file = new File([blob], attachment.name, { + type: blob.type || attachment.mimeType, + }); + return { + type: "image", + id: attachment.id, + name: attachment.name, + mimeType: file.type || attachment.mimeType, + sizeBytes: blob.size || attachment.sizeBytes, + previewUrl: attachment.previewUrl, + file, + }; + } catch { + return null; + } +} + export function deriveComposerSendState(options: { prompt: string; imageCount: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76133712d4..3a8a5fcc2a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -137,6 +137,7 @@ import { } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, + deriveDisplayedUserMessageState, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, @@ -153,7 +154,7 @@ import { import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { MessagesTimeline, type TimelineUserChatMessage } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -188,8 +189,11 @@ import { readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + EDIT_REVERT_SYNC_TIMEOUT_MS, + materializeMessageImageAttachmentForEdit, threadHasStarted, waitForStartedServerThread, + waitForThreadMessageRemoval, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { @@ -492,6 +496,11 @@ export default function ChatView({ threadId }: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [editingUserMessageId, setEditingUserMessageId] = useState(null); + const [editingUserMessageText, setEditingUserMessageText] = useState(""); + const [editingUserMessageImages, setEditingUserMessageImages] = useState( + [], + ); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -2525,6 +2534,74 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(activeThreadId, error); }; + const addEditingUserMessageImages = useCallback( + (files: File[]) => { + if (!activeThreadId || files.length === 0) return; + + const nextImages: ComposerImageAttachment[] = []; + let nextImageCount = editingUserMessageImages.length; + let error: string | null = null; + for (const file of files) { + if (!file.type.startsWith("image/")) { + error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + continue; + } + if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + break; + } + + const previewUrl = URL.createObjectURL(file); + nextImages.push({ + type: "image", + id: randomUUID(), + name: file.name || "image", + mimeType: file.type, + sizeBytes: file.size, + previewUrl, + file, + }); + nextImageCount += 1; + } + + if (nextImages.length > 0) { + setEditingUserMessageImages((existing) => [...existing, ...nextImages]); + } + setThreadError(activeThreadId, error); + }, + [activeThreadId, editingUserMessageImages.length, setThreadError], + ); + + const removeEditingUserMessageImage = useCallback((imageId: string) => { + setEditingUserMessageImages((existing) => { + const image = existing.find((entry) => entry.id === imageId); + if (image) { + revokeBlobPreviewUrl(image.previewUrl); + } + return existing.filter((entry) => entry.id !== imageId); + }); + }, []); + + const onEditUserMessagePaste = useCallback( + (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files); + if (files.length === 0) { + return; + } + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length === 0) { + return; + } + event.preventDefault(); + addEditingUserMessageImages(imageFiles); + }, + [addEditingUserMessageImages], + ); + const removeComposerImage = (imageId: string) => { removeComposerImageFromDraft(imageId); }; @@ -2588,23 +2665,32 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async ( + turnCount: number, + options?: { + confirm?: boolean; + }, + ): Promise => { const api = readNativeApi(); - if (!api || !activeThread || isRevertingCheckpoint) return; + if (!api || !activeThread || isRevertingCheckpoint) { + return false; + } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; + return false; } - const confirmed = await api.dialogs.confirm( - [ - `Revert this thread to checkpoint ${turnCount}?`, - "This will discard newer messages and turn diffs in this thread.", - "This action cannot be undone.", - ].join("\n"), - ); - if (!confirmed) { - return; + if (options?.confirm !== false) { + const confirmed = await api.dialogs.confirm( + [ + `Revert this thread to checkpoint ${turnCount}?`, + "This will discard newer messages and turn diffs in this thread.", + "This action cannot be undone.", + ].join("\n"), + ); + if (!confirmed) { + return false; + } } setIsRevertingCheckpoint(true); @@ -2617,80 +2703,33 @@ export default function ChatView({ threadId }: ChatViewProps) { turnCount, createdAt: new Date().toISOString(), }); + return true; } catch (err) { setThreadError( activeThread.id, err instanceof Error ? err.message : "Failed to revert thread state.", ); + return false; + } finally { + setIsRevertingCheckpoint(false); } - setIsRevertingCheckpoint(false); }, [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); - const onSend = async (e?: { preventDefault: () => void }) => { - e?.preventDefault(); + const submitComposerTurn = async (input: { + promptForSend: string; + trimmedPrompt: string; + composerImagesSnapshot: ComposerImageAttachment[]; + composerTerminalContextsSnapshot: TerminalContextDraft[]; + clearComposerDraft: boolean; + expiredTerminalContextCount: number; + }) => { const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; - if (activePendingProgress) { - onAdvanceActivePendingUserInput(); - return; - } - const promptForSend = promptRef.current; - const { - trimmedPrompt: trimmed, - sendableTerminalContexts: sendableComposerTerminalContexts, - expiredTerminalContextCount, - hasSendableContent, - } = deriveComposerSendState({ - prompt: promptForSend, - imageCount: composerImages.length, - terminalContexts: composerTerminalContexts, - }); - if (showPlanFollowUpPrompt && activeProposedPlan) { - const followUp = resolvePlanFollowUpSubmission({ - draftText: trimmed, - planMarkdown: activeProposedPlan.planMarkdown, - }); - promptRef.current = ""; - clearComposerDraftContent(activeThread.id); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); - await onSubmitPlanFollowUp({ - text: followUp.text, - interactionMode: followUp.interactionMode, - }); + if (!api || !activeThread || !activeProject || isSendBusy || isConnecting || sendInFlightRef.current) { return; } - const standaloneSlashCommand = - composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) - : null; - if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); - promptRef.current = ""; - clearComposerDraftContent(activeThread.id); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); - return; - } - if (!hasSendableContent) { - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "empty", - ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); - } - return; - } - if (!activeProject) return; + const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = @@ -2710,11 +2749,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + const { + promptForSend, + trimmedPrompt, + composerImagesSnapshot, + composerTerminalContextsSnapshot, + clearComposerDraft, + expiredTerminalContextCount, + } = input; + sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, @@ -2772,11 +2818,13 @@ export default function ChatView({ threadId }: ChatViewProps) { description: toastCopy.description, }); } - promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); - setComposerHighlightedItemId(null); - setComposerCursor(0); - setComposerTrigger(null); + if (clearComposerDraft) { + promptRef.current = ""; + clearComposerDraftContent(threadIdForSend); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + } let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; @@ -2815,7 +2863,7 @@ export default function ChatView({ threadId }: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; + let titleSeed = trimmedPrompt; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; @@ -2927,6 +2975,7 @@ export default function ChatView({ threadId }: ChatViewProps) { .catch(() => undefined); } if ( + clearComposerDraft && !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && @@ -2958,6 +3007,152 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; + const onSend = async (e?: { preventDefault: () => void }) => { + e?.preventDefault(); + const api = readNativeApi(); + if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if (activePendingProgress) { + onAdvanceActivePendingUserInput(); + return; + } + const promptForSend = promptRef.current; + const { + trimmedPrompt: trimmed, + sendableTerminalContexts: sendableComposerTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }); + if (showPlanFollowUpPrompt && activeProposedPlan) { + const followUp = resolvePlanFollowUpSubmission({ + draftText: trimmed, + planMarkdown: activeProposedPlan.planMarkdown, + }); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + await onSubmitPlanFollowUp({ + text: followUp.text, + interactionMode: followUp.interactionMode, + }); + return; + } + const standaloneSlashCommand = + composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + ? parseStandaloneComposerSlashCommand(trimmed) + : null; + if (standaloneSlashCommand) { + handleInteractionModeChange(standaloneSlashCommand); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + return; + } + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return; + } + if (!activeProject) return; + await submitComposerTurn({ + promptForSend, + trimmedPrompt: trimmed, + composerImagesSnapshot: [...composerImages], + composerTerminalContextsSnapshot: [...sendableComposerTerminalContexts], + clearComposerDraft: true, + expiredTerminalContextCount, + }); + }; + + const onStartEditUserMessage = useCallback(async (message: TimelineUserChatMessage) => { + setEditingUserMessageId(message.id); + const displayed = deriveDisplayedUserMessageState(message.text); + setEditingUserMessageText(displayed.visibleText); + + const nextImages = await Promise.all( + (message.attachments ?? []) + .filter( + ( + attachment, + ): attachment is Extract< + NonNullable[number], + { type: "image" } + > => attachment.type === "image", + ) + .map(materializeMessageImageAttachmentForEdit), + ); + + setEditingUserMessageImages((existing) => { + for (const image of existing) { + if (!nextImages.some((candidate) => candidate?.previewUrl === image.previewUrl)) { + revokeBlobPreviewUrl(image.previewUrl); + } + } + return nextImages.flatMap((image) => (image ? [image] : [])); + }); + }, []); + + const onCancelEditUserMessage = useCallback(() => { + setEditingUserMessageId(null); + setEditingUserMessageText(""); + setEditingUserMessageImages((existing) => { + for (const image of existing) { + revokeBlobPreviewUrl(image.previewUrl); + } + return []; + }); + }, []); + + const onSubmitEditUserMessage = async (message: TimelineUserChatMessage) => { + const nextText = editingUserMessageText.trim(); + if (!activeThread || (nextText.length === 0 && editingUserMessageImages.length === 0)) { + return; + } + + const targetTurnCount = revertTurnCountByUserMessageId.get(message.id); + if (typeof targetTurnCount !== "number") { + setThreadError(activeThread.id, "This message can no longer be edited."); + return; + } + + const reverted = await onRevertToTurnCount(targetTurnCount, { confirm: false }); + if (!reverted) { + return; + } + + await waitForThreadMessageRemoval(activeThread.id, message.id, EDIT_REVERT_SYNC_TIMEOUT_MS); + + setEditingUserMessageId(null); + setEditingUserMessageText(""); + const nextImages = editingUserMessageImages; + setEditingUserMessageImages([]); + + await submitComposerTurn({ + promptForSend: nextText, + trimmedPrompt: nextText, + composerImagesSnapshot: nextImages, + composerTerminalContextsSnapshot: [], + clearComposerDraft: false, + expiredTerminalContextCount: 0, + }); + }; + const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; @@ -3832,6 +4027,17 @@ export default function ChatView({ threadId }: ChatViewProps) { revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} + isSendBusy={isSendBusy} + editingUserMessageId={editingUserMessageId} + editingUserMessageText={editingUserMessageText} + editingUserMessageImages={editingUserMessageImages} + onStartEditUserMessage={onStartEditUserMessage} + onChangeEditingUserMessageText={setEditingUserMessageText} + onAddEditingUserMessageImages={addEditingUserMessageImages} + onRemoveEditingUserMessageImage={removeEditingUserMessageImage} + onEditUserMessagePaste={onEditUserMessagePaste} + onCancelEditUserMessage={onCancelEditUserMessage} + onSubmitEditUserMessage={onSubmitEditUserMessage} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..974eabb7be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -84,6 +84,17 @@ describe("MessagesTimeline", () => { revertTurnCountByUserMessageId={new Map()} onRevertUserMessage={() => {}} isRevertingCheckpoint={false} + isSendBusy={false} + editingUserMessageId={null} + editingUserMessageText="" + editingUserMessageImages={[]} + onStartEditUserMessage={() => {}} + onChangeEditingUserMessageText={() => {}} + onAddEditingUserMessageImages={() => {}} + onRemoveEditingUserMessageImage={() => {}} + onEditUserMessagePaste={() => {}} + onCancelEditUserMessage={() => {}} + onSubmitEditUserMessage={() => {}} onImageExpand={() => {}} markdownCwd={undefined} resolvedTheme="light" @@ -129,6 +140,17 @@ describe("MessagesTimeline", () => { revertTurnCountByUserMessageId={new Map()} onRevertUserMessage={() => {}} isRevertingCheckpoint={false} + isSendBusy={false} + editingUserMessageId={null} + editingUserMessageText="" + editingUserMessageImages={[]} + onStartEditUserMessage={() => {}} + onChangeEditingUserMessageText={() => {}} + onAddEditingUserMessageImages={() => {}} + onRemoveEditingUserMessageImage={() => {}} + onEditUserMessagePaste={() => {}} + onCancelEditUserMessage={() => {}} + onSubmitEditUserMessage={() => {}} onImageExpand={() => {}} markdownCwd={undefined} resolvedTheme="light" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..db19df7c52 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,8 @@ -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { + type MessageId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + type TurnId, +} from "@t3tools/contracts"; import { memo, useCallback, @@ -7,6 +11,7 @@ import { useMemo, useRef, useState, + type ClipboardEvent, type ReactNode, } from "react"; import { @@ -26,14 +31,19 @@ import { EyeIcon, GlobeIcon, HammerIcon, + ImagePlusIcon, type LucideIcon, + PencilIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, + XIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { type ComposerImageAttachment } from "../../composerDraftStore"; import { clamp } from "effect/Number"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; @@ -61,6 +71,11 @@ import { textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +export type TimelineUserChatMessage = Extract< + ReturnType[number], + { kind: "message" } +>["message"]; + const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; interface MessagesTimelineProps { @@ -80,6 +95,17 @@ interface MessagesTimelineProps { revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; + isSendBusy: boolean; + editingUserMessageId: MessageId | null; + editingUserMessageText: string; + editingUserMessageImages: ComposerImageAttachment[]; + onStartEditUserMessage: (message: TimelineUserChatMessage) => void; + onChangeEditingUserMessageText: (value: string) => void; + onAddEditingUserMessageImages: (files: File[]) => void; + onRemoveEditingUserMessageImage: (imageId: string) => void; + onEditUserMessagePaste: (event: ClipboardEvent) => void; + onCancelEditUserMessage: () => void; + onSubmitEditUserMessage: (message: TimelineUserChatMessage) => void; onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -115,6 +141,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, + isSendBusy, + editingUserMessageId, + editingUserMessageText, + editingUserMessageImages, + onStartEditUserMessage, + onChangeEditingUserMessageText, + onAddEditingUserMessageImages, + onRemoveEditingUserMessageImage, + onEditUserMessagePaste, + onCancelEditUserMessage, + onSubmitEditUserMessage, onImageExpand, markdownCwd, resolvedTheme, @@ -354,85 +391,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })()} {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
- {displayedUserMessage.copyText && ( - - )} - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt, timestampFormat)} -

-
-
-
- ); - })()} + row.message.role === "user" && ( + + )} {row.kind === "message" && row.message.role === "assistant" && @@ -744,6 +725,255 @@ const UserMessageBody = memo(function UserMessageBody(props: { ); }); +const EditableUserMessageTimelineRow = memo(function EditableUserMessageTimelineRow(props: { + message: TimelineUserChatMessage; + timestampFormat: TimestampFormat; + revertTurnCountByUserMessageId: Map; + onRevertUserMessage: (messageId: MessageId) => void; + isRevertingCheckpoint: boolean; + isWorking: boolean; + isSendBusy: boolean; + onImageExpand: (preview: ExpandedImagePreview) => void; + onTimelineImageLoad: () => void; + editingUserMessageId: MessageId | null; + editingUserMessageText: string; + editingUserMessageImages: ComposerImageAttachment[]; + onStartEditUserMessage: (message: TimelineUserChatMessage) => void; + onChangeEditingUserMessageText: (value: string) => void; + onAddEditingUserMessageImages: (files: File[]) => void; + onRemoveEditingUserMessageImage: (imageId: string) => void; + onEditUserMessagePaste: (event: ClipboardEvent) => void; + onCancelEditUserMessage: () => void; + onSubmitEditUserMessage: (message: TimelineUserChatMessage) => void; +}) { + const { + message, + timestampFormat, + revertTurnCountByUserMessageId, + onRevertUserMessage, + isRevertingCheckpoint, + isWorking, + isSendBusy, + onImageExpand, + onTimelineImageLoad, + editingUserMessageId, + editingUserMessageText, + editingUserMessageImages, + onStartEditUserMessage, + onChangeEditingUserMessageText, + onAddEditingUserMessageImages, + onRemoveEditingUserMessageImage, + onEditUserMessagePaste, + onCancelEditUserMessage, + onSubmitEditUserMessage, + } = props; + + const editTextareaRef = useRef(null); + const editFileInputRef = useRef(null); + const userImages = message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(message.text); + const terminalContexts = displayedUserMessage.contexts; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(message.id); + const isEditing = editingUserMessageId === message.id; + const displayedImages = isEditing ? editingUserMessageImages : userImages; + const isBusy = isWorking || isSendBusy; + + useEffect(() => { + if (!isEditing) { + return; + } + const textarea = editTextareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + const selectionEnd = textarea.value.length; + textarea.setSelectionRange(selectionEnd, selectionEnd); + }, [isEditing]); + + return ( +
+
+ {displayedImages.length > 0 && ( +
+ {displayedImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {isEditing && ( + + )} +
+ ))} +
+ )} + {isEditing ? ( +
+ { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + onAddEditingUserMessageImages(files); + } + event.currentTarget.value = ""; + }} + /> +