diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 9a2966eb..c6ed951a 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, GitFork, Undo2 } from 'lucide-react' +import { Check, Copy, GitFork, Pencil } from 'lucide-react' import type { ReactNode } from 'react' import { useSettingsStore } from '../../stores/settingsStore' import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp' @@ -11,10 +11,10 @@ export type MessageBranchAction = { onBranch: () => void } -export type MessageRewindAction = { +export type MessageEditAction = { label: string - loading?: boolean - onRewind: () => void + disabled?: boolean + onEdit: () => void } /** @@ -33,7 +33,7 @@ type Props = { copyText?: string copyLabel: string branchAction?: MessageBranchAction - rewindAction?: MessageRewindAction + editAction?: MessageEditAction align?: 'start' | 'end' timestamp?: number /** Inline metadata that shares the same compact row as the actions. */ @@ -50,7 +50,7 @@ export function MessageActionBar({ copyText, copyLabel, branchAction, - rewindAction, + editAction, align = 'start', timestamp, metadata, @@ -65,7 +65,7 @@ export function MessageActionBar({ ? formatExactMessageTimestamp(timestamp, locale) : '' - if (!hasCopy && !branchAction && !rewindAction && !metadata) return null + if (!hasCopy && !branchAction && !editAction && !metadata) return null return (
event.currentTarget.blur()} /> ) : null} - {rewindAction ? ( + {editAction ? (
) } @@ -3883,6 +3866,7 @@ export const MessageBlock = memo(function MessageBlock({ agentTaskNotifications, toolResult, branchAction, + editAction, turnChangedFiles, isTurnOutputOwner, turnCompletion, @@ -3899,6 +3883,7 @@ export const MessageBlock = memo(function MessageBlock({ loading?: boolean onBranch: () => void } + editAction?: UserMessageEditAction turnChangedFiles?: string[] isTurnOutputOwner?: boolean turnCompletion?: TurnCompletion @@ -3933,6 +3918,7 @@ export const MessageBlock = memo(function MessageBlock({ sessionReferences={message.sessionReferences} collaboration={message.collaboration} branchAction={branchAction} + editAction={editAction} timestamp={message.timestamp} sessionId={sessionId ?? undefined} teammateFrom={message.teammateFrom} diff --git a/desktop/src/components/chat/UserMessage.test.tsx b/desktop/src/components/chat/UserMessage.test.tsx index f7f03f77..818b05a6 100644 --- a/desktop/src/components/chat/UserMessage.test.tsx +++ b/desktop/src/components/chat/UserMessage.test.tsx @@ -5,9 +5,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const openPreviewLink = vi.hoisted(() => vi.fn(() => true)) vi.mock('../../lib/openPreviewLink', () => ({ openPreviewLink })) -import { UserMessage } from './UserMessage' +import { UserMessage, type UserMessageEditAction } from './UserMessage' import { useSettingsStore } from '../../stores/settingsStore' +function makeEditAction(overrides: Partial = {}): UserMessageEditAction { + return { + label: 'Edit and resend', + editing: false, + submitting: false, + disabled: false, + getDraft: () => ({ text: 'A prompt', attachments: [], sessionReferences: [] }), + onStart: vi.fn(), + onCancel: vi.fn(), + onDraftChange: vi.fn(), + onSubmit: vi.fn(), + ...overrides, + } +} + function bubbleOf(container: HTMLElement): HTMLElement { const bubble = container.querySelector('[data-message-body="user"]') if (!bubble) throw new Error('user message bubble not found') @@ -20,11 +35,11 @@ describe('UserMessage', () => { openPreviewLink.mockClear().mockReturnValue(true) }) - it('places rollback beside copy and fork in the existing hover and keyboard-focus action row', () => { + it('places edit beside copy and fork in the existing hover and keyboard-focus action row', () => { useSettingsStore.setState({ locale: 'en' }) - const rollback = vi.fn() - const { container } = render() - const button = screen.getByRole('button', { name: 'Roll back conversation' }) + const editAction = makeEditAction() + const { container } = render() + const button = screen.getByRole('button', { name: 'Edit and resend' }) const actions = button.closest('[data-message-actions]') expect(actions).toBeTruthy() expect(actions?.className).toContain('opacity-0') @@ -36,7 +51,41 @@ describe('UserMessage', () => { button.focus() expect(document.activeElement).toBe(button) fireEvent.click(button) - expect(rollback).toHaveBeenCalledOnce() + expect(editAction.onStart).toHaveBeenCalledOnce() + }) + + it('replaces the bubble with the inline editor while editing, seeded from the saved draft', () => { + useSettingsStore.setState({ locale: 'en' }) + const editAction = makeEditAction({ + editing: true, + getDraft: () => ({ text: 'Draft kept by the list', attachments: [], sessionReferences: [] }), + }) + const { container } = render() + + expect(container.querySelector('[data-message-body="user"]')).toBeNull() + expect(container.querySelector('[data-message-actions]')).toBeNull() + expect((screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement).value).toBe('Draft kept by the list') + }) + + it('offers edit on an attachment-only prompt, which has no text to copy', () => { + useSettingsStore.setState({ locale: 'en' }) + render( + , + ) + + expect(screen.getByRole('button', { name: 'Edit and resend' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Copy prompt' })).toBeNull() + }) + + it('never offers edit on a teammate message', () => { + useSettingsStore.setState({ locale: 'en' }) + render() + + expect(screen.queryByRole('button', { name: 'Edit and resend' })).toBeNull() }) it('keeps long URLs inside the message bubble', () => { diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 6a55da72..fd118ab4 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -8,9 +8,27 @@ import { useTranslation } from '../../i18n' import { openPreviewLink } from '../../lib/openPreviewLink' import { splitTextByUrls } from '../../lib/urlBoundary' import { AttachmentGallery } from './AttachmentGallery' -import { MessageActionBar, type MessageBranchAction, type MessageRewindAction } from './MessageActionBar' +import { MessageActionBar, type MessageBranchAction, type MessageEditAction } from './MessageActionBar' +import { UserMessageEditor } from './UserMessageEditor' +import type { UserMessageEditDraft } from './userMessageEdit' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +/** + * Edit-and-resend for one prompt. The draft lives with the caller, not in this + * component, so it survives the row being virtualized away and remounted. + */ +export type UserMessageEditAction = { + label: string + editing: boolean + submitting: boolean + disabled: boolean + getDraft: () => UserMessageEditDraft + onStart: () => void + onCancel: () => void + onDraftChange: (draft: UserMessageEditDraft) => void + onSubmit: (draft: UserMessageEditDraft) => void +} + type Props = { content: string sessionReferences?: Array<{ sessionId: string }> @@ -18,7 +36,7 @@ type Props = { collaboration?: { sourceSessionId: string; messageId?: string } attachments?: UIAttachment[] branchAction?: MessageBranchAction - rewindAction?: MessageRewindAction + editAction?: UserMessageEditAction timestamp?: number sessionId?: string /** Set when this turn came from another agent rather than from the user. */ @@ -34,7 +52,7 @@ export const UserMessage = memo(function UserMessage({ collaboration, attachments, branchAction, - rewindAction, + editAction, timestamp, sessionId, teammateFrom, @@ -44,6 +62,12 @@ export const UserMessage = memo(function UserMessage({ }: Props) { const t = useTranslation() const hasText = content.trim().length > 0 + const actionBarEditAction = useMemo( + () => editAction + ? { label: editAction.label, disabled: editAction.disabled, onEdit: editAction.onStart } + : undefined, + [editAction], + ) // The operator's prompt is literal text, NOT markdown — `**`, `#` and file // paths have to stay exactly as typed. Teammate traffic is rendered separately @@ -149,6 +173,27 @@ export const UserMessage = memo(function UserMessage({ ) } + if (editAction?.editing) { + return ( +
+
+ +
+
+ ) + } + return (
- {hasText && ( + {(hasText || actionBarEditAction) && ( diff --git a/desktop/src/components/chat/UserMessageEditor.test.tsx b/desktop/src/components/chat/UserMessageEditor.test.tsx new file mode 100644 index 00000000..76650c60 --- /dev/null +++ b/desktop/src/components/chat/UserMessageEditor.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { UserMessageEditor } from './UserMessageEditor' +import type { UserMessageEditDraft } from './userMessageEdit' + +const DRAFT: UserMessageEditDraft = { + text: 'Fix the bug', + attachments: [ + { id: 'a', type: 'file', name: 'notes.md', path: '/repo/notes.md', sendable: true }, + ], + sessionReferences: [], +} + +function renderEditor(overrides: Partial[0]> = {}) { + const props = { + initialDraft: DRAFT, + submitting: false, + disabled: false, + onDraftChange: vi.fn(), + onCancel: vi.fn(), + onSubmit: vi.fn(), + ...overrides, + } + render() + return { ...props, textbox: screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement } +} + +describe('UserMessageEditor', () => { + afterEach(() => { + useSettingsStore.setState({ locale: 'en', chatSendBehavior: 'enter' }) + }) + + it('opens focused on the draft with the caret at the end', () => { + useSettingsStore.setState({ locale: 'en' }) + const { textbox } = renderEditor() + + expect(textbox.value).toBe('Fix the bug') + expect(document.activeElement).toBe(textbox) + expect(textbox.selectionStart).toBe('Fix the bug'.length) + }) + + it('submits the edited draft on Enter but not on Shift+Enter', () => { + useSettingsStore.setState({ locale: 'en', chatSendBehavior: 'enter' }) + const { textbox, onSubmit, onDraftChange } = renderEditor() + + fireEvent.change(textbox, { target: { value: 'Fix the signup bug' } }) + expect(onDraftChange).toHaveBeenLastCalledWith({ ...DRAFT, text: 'Fix the signup bug' }) + + fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: true }) + expect(onSubmit).not.toHaveBeenCalled() + + fireEvent.keyDown(textbox, { key: 'Enter' }) + expect(onSubmit).toHaveBeenCalledOnce() + expect(onSubmit).toHaveBeenCalledWith({ ...DRAFT, text: 'Fix the signup bug' }) + }) + + it('does not submit the Enter that confirms an IME composition', () => { + useSettingsStore.setState({ locale: 'en', chatSendBehavior: 'enter' }) + const { textbox, onSubmit } = renderEditor() + + fireEvent.compositionStart(textbox) + fireEvent.keyDown(textbox, { key: 'Enter' }) + fireEvent.compositionEnd(textbox) + fireEvent.keyDown(textbox, { key: 'Enter', keyCode: 229 }) + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('follows the modifier+Enter send setting', () => { + useSettingsStore.setState({ locale: 'en', chatSendBehavior: 'modifierEnter' }) + const { textbox, onSubmit } = renderEditor() + + fireEvent.keyDown(textbox, { key: 'Enter' }) + expect(onSubmit).not.toHaveBeenCalled() + fireEvent.keyDown(textbox, { key: 'Enter', metaKey: true }) + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it('cancels on Escape without submitting', () => { + useSettingsStore.setState({ locale: 'en' }) + const { textbox, onCancel, onSubmit } = renderEditor() + + fireEvent.keyDown(textbox, { key: 'Escape' }) + + expect(onCancel).toHaveBeenCalledOnce() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('keeps editor keystrokes from reaching the message list scroll handler', () => { + useSettingsStore.setState({ locale: 'en' }) + const listKeyDown = vi.fn() + render( +
+ +
, + ) + + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Edited message' }), { key: 'ArrowUp' }) + expect(listKeyDown).not.toHaveBeenCalled() + }) + + it('removes an attachment chip from the draft', () => { + useSettingsStore.setState({ locale: 'en' }) + const { onDraftChange } = renderEditor() + + fireEvent.click(screen.getByRole('button', { name: 'Remove notes.md' })) + + expect(onDraftChange).toHaveBeenLastCalledWith({ ...DRAFT, attachments: [] }) + }) + + it('disables send for an empty draft and while the session cannot take an edit', () => { + useSettingsStore.setState({ locale: 'en' }) + const { textbox, onSubmit } = renderEditor({ + initialDraft: { text: 'x', attachments: [], sessionReferences: [] }, + }) + fireEvent.change(textbox, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.keyDown(textbox, { key: 'Enter' }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('disables send when the session is busy', () => { + useSettingsStore.setState({ locale: 'en' }) + renderEditor({ disabled: true }) + + expect((screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('says how many attachments can no longer be resent', () => { + useSettingsStore.setState({ locale: 'en' }) + renderEditor({ + initialDraft: { + ...DRAFT, + attachments: [...DRAFT.attachments, { id: 'b', type: 'file', name: 'gone.pdf', sendable: false }], + }, + }) + + expect(screen.getByText('1 attachment(s) can no longer be read and will be left out when resending.')).toBeTruthy() + }) +}) diff --git a/desktop/src/components/chat/UserMessageEditor.tsx b/desktop/src/components/chat/UserMessageEditor.tsx new file mode 100644 index 00000000..dee7b101 --- /dev/null +++ b/desktop/src/components/chat/UserMessageEditor.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { Button } from '@/components/ui/Button' +import { TextArea } from '@/components/ui/TextArea' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../stores/settingsStore' +import { AttachmentGallery } from './AttachmentGallery' +import { shouldSubmitOnEnter } from './sendShortcut' +import type { UserMessageEditDraft } from './userMessageEdit' + +type Props = { + initialDraft: UserMessageEditDraft + submitting: boolean + disabled: boolean + onDraftChange: (draft: UserMessageEditDraft) => void + onCancel: () => void + onSubmit: (draft: UserMessageEditDraft) => void +} + +const MIN_ROWS = 2 +const MAX_ROWS = 12 + +export function UserMessageEditor({ + initialDraft, + submitting, + disabled, + onDraftChange, + onCancel, + onSubmit, +}: Props) { + const t = useTranslation() + const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) + const [draft, setDraft] = useState(initialDraft) + const textareaRef = useRef(null) + const composingRef = useRef(false) + + useEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + textarea.focus() + textarea.setSelectionRange(textarea.value.length, textarea.value.length) + }, []) + + const updateDraft = (next: UserMessageEditDraft) => { + setDraft(next) + onDraftChange(next) + } + + const unavailableCount = draft.attachments.filter((attachment) => !attachment.sendable).length + const hasContent = draft.text.trim().length > 0 || + draft.attachments.some((attachment) => attachment.sendable) + const canSubmit = hasContent && !disabled && !submitting + const rows = Math.min(MAX_ROWS, Math.max(MIN_ROWS, draft.text.split('\n').length)) + + const handleKeyDown = (event: ReactKeyboardEvent) => { + // The message list reads arrow keys and Space as scroll intent; keystrokes + // typed into the editor belong to the editor alone. + event.stopPropagation() + if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return + if (event.key === 'Escape') { + event.preventDefault() + if (!submitting) onCancel() + return + } + if (shouldSubmitOnEnter(event, chatSendBehavior)) { + event.preventDefault() + if (canSubmit) onSubmit(draft) + } + } + + return ( +
+ {draft.attachments.length > 0 ? ( + updateDraft({ + ...draft, + attachments: draft.attachments.filter((attachment) => attachment.id !== id), + })} + /> + ) : null} + {unavailableCount > 0 ? ( +

+ {t('chat.editAttachmentUnavailable', { count: unavailableCount })} +

+ ) : null} +