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 ? (
}
- label={rewindAction.label}
+ icon={
}
+ label={editAction.label}
size="sm"
tone="muted"
shape="circle"
- disabled={rewindAction.loading}
- onClick={rewindAction.onRewind}
+ disabled={editAction.disabled}
+ onClick={editAction.onEdit}
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{metadata ? (
-
+
{metadata}
) : null}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 02ea6e64..1b0208bf 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -33,6 +33,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { initializeChatAppearance, useChatAppearanceStore } from '../../stores/chatAppearanceStore'
import { CHAT_APPEARANCE_STORAGE_KEY } from '../../lib/chatAppearance'
import { useSessionStore } from '../../stores/sessionStore'
+import { useSideChatStore } from '../../stores/sideChatStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useTeamStore } from '../../stores/teamStore'
@@ -9533,3 +9534,355 @@ describe('MessageList agent card activity', () => {
expect(screen.getByText(/Showing the start and end of this run/)).toBeTruthy()
})
})
+
+// #1343. Editing a prompt reuses the existing rewind — same targets, same
+// dry-run/`conversation`/`both` semantics — and then sends the edit. Nothing
+// may change before the rewind succeeds, and once it has, the edit must reach
+// either the model or the composer.
+describe('MessageList edit and resend', () => {
+ type Checkpoint = Awaited>['checkpoints'][number]
+ type RewindResult = Awaited>
+
+ function checkpoint(
+ targetUserMessageId: string,
+ userMessageIndex: number,
+ userMessageCount: number,
+ overrides: Partial = {},
+ ): Checkpoint {
+ return {
+ target: { targetUserMessageId, userMessageIndex, userMessageCount },
+ code: { available: false, filesChanged: [], insertions: 0, deletions: 0 },
+ ...overrides,
+ }
+ }
+
+ function rewindResult(overrides: Partial = {}): RewindResult {
+ return {
+ target: { targetUserMessageId: 'user-1', userMessageIndex: 0, userMessageCount: 1 },
+ conversation: { messagesRemoved: 2 },
+ code: { available: false, filesChanged: [], insertions: 0, deletions: 0 },
+ ...overrides,
+ }
+ }
+
+ function setup(messages: UIMessage[], checkpoints: Checkpoint[], sessionOverrides: Partial = {}) {
+ vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ checkpoints })
+ const reloadHistory = vi.fn().mockResolvedValue(undefined)
+ const sendMessage = vi.fn()
+ const queueComposerPrefill = vi.fn()
+ const stopGeneration = vi.fn()
+ useChatStore.setState({
+ reloadHistory,
+ sendMessage,
+ queueComposerPrefill,
+ stopGeneration,
+ sessions: { [ACTIVE_TAB]: makeSessionState({ messages, ...sessionOverrides }) },
+ })
+ return { reloadHistory, sendMessage, queueComposerPrefill, stopGeneration }
+ }
+
+ const oneTurn: UIMessage[] = [
+ { id: 'user-1', type: 'user_text', content: 'Build a page', transcriptMessageId: 'user-1', timestamp: 1 },
+ { id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
+ ]
+
+ const threeTurns: UIMessage[] = [
+ { id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
+ { id: 'assistant-1', type: 'assistant_text', content: 'First answer', timestamp: 2 },
+ { id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
+ { id: 'assistant-2', type: 'assistant_text', content: 'Second answer', timestamp: 4 },
+ { id: 'user-3', type: 'user_text', content: 'Third prompt', timestamp: 5 },
+ { id: 'assistant-3', type: 'assistant_text', content: 'Third answer', timestamp: 6 },
+ ]
+
+ async function openEditorFor(content: string) {
+ const bubble = (await screen.findByText(content)).closest('[data-message-shell="user"]') as HTMLElement
+ // The bubble renders before the turn checkpoints load; only then is the
+ // prompt known to be rewindable and the edit action offered.
+ fireEvent.click(await within(bubble).findByRole('button', { name: 'Edit and resend' }))
+ return screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement
+ }
+
+ function typeAndSend(textbox: HTMLTextAreaElement, text: string) {
+ fireEvent.change(textbox, { target: { value: text } })
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }))
+ }
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ resetSessionScrollSnapshotsForTests()
+ useSettingsStore.setState({ locale: 'en', chatSendBehavior: 'enter' })
+ useUIStore.setState({ toasts: [] })
+ useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
+ useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
+ useSideChatStore.setState({ entries: {} })
+ useTeamStore.getState().clearTeam()
+ useWorkspaceStore.setState(useWorkspaceStore.getInitialState(), true)
+ vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
+ state: 'ok',
+ workDir: '/tmp/example-project',
+ repoName: 'example-project',
+ branch: null,
+ isGitRepo: false,
+ changedFiles: [],
+ })
+ })
+
+ it('offers edit only on prompts the rewind API can target', async () => {
+ setup([
+ ...threeTurns.slice(0, 4),
+ { id: 'collab', type: 'user_text', content: 'Delivered from elsewhere', collaboration: { sourceSessionId: 's2' }, timestamp: 5 },
+ { id: 'assistant-c', type: 'assistant_text', content: 'Ack', timestamp: 6 },
+ ], [checkpoint('user-1', 0, 3), checkpoint('collab', 2, 3)])
+ render()
+
+ const first = (await screen.findByText('First prompt')).closest('[data-message-shell="user"]') as HTMLElement
+ await waitFor(() => expect(within(first).getByRole('button', { name: 'Edit and resend' })).toBeTruthy())
+ const second = screen.getByText('Second prompt').closest('[data-message-shell="user"]') as HTMLElement
+ expect(within(second).queryByRole('button', { name: 'Edit and resend' })).toBeNull()
+ const collab = screen.getByText('Delivered from elsewhere').closest('[data-message-shell="user"]') as HTMLElement
+ expect(within(collab).queryByRole('button', { name: 'Edit and resend' })).toBeNull()
+ })
+
+ it('offers no edit while the session is busy or in a side chat', async () => {
+ setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ const { unmount } = render()
+ await screen.findByRole('button', { name: 'Edit and resend' })
+
+ act(() => {
+ const current = useChatStore.getState().sessions[ACTIVE_TAB]!
+ useChatStore.setState({ sessions: { [ACTIVE_TAB]: { ...current, chatState: 'thinking' } } })
+ })
+ expect(screen.queryByRole('button', { name: 'Edit and resend' })).toBeNull()
+ unmount()
+
+ setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ useSideChatStore.setState({ entries: { [ACTIVE_TAB]: { sessionId: ACTIVE_TAB, parentSessionId: 'parent' } as never } })
+ render()
+ await screen.findByText('Build a page')
+ await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalled())
+ await act(async () => { await Promise.resolve() })
+ expect(screen.queryByRole('button', { name: 'Edit and resend' })).toBeNull()
+ })
+
+ it('resends the latest text-only turn straight away: dry run, conversation rewind, reload, send', async () => {
+ const { reloadHistory, sendMessage, queueComposerPrefill } = setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ const rewind = vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult())
+ render()
+
+ const textbox = await openEditorFor('Build a page')
+ expect(textbox.value).toBe('Build a page')
+ typeAndSend(textbox, 'Build a landing page')
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
+ expect(screen.queryByRole('dialog')).toBeNull()
+ expect(rewind).toHaveBeenCalledTimes(2)
+ expect(rewind).toHaveBeenNthCalledWith(1, ACTIVE_TAB, {
+ targetUserMessageId: 'user-1', userMessageIndex: 0, expectedContent: 'Build a page', dryRun: true,
+ })
+ expect(rewind).toHaveBeenNthCalledWith(2, ACTIVE_TAB, {
+ targetUserMessageId: 'user-1', userMessageIndex: 0, expectedContent: 'Build a page', mode: 'conversation',
+ })
+ expect(sendMessage).toHaveBeenCalledWith(ACTIVE_TAB, 'Build a landing page', [], {
+ displayContent: 'Build a landing page',
+ displayAttachments: [],
+ })
+ // The edit is sent into the rewound history, never before it is reloaded.
+ expect(reloadHistory.mock.invocationCallOrder[0]!)
+ .toBeGreaterThan(rewind.mock.invocationCallOrder[1]!)
+ expect(sendMessage.mock.invocationCallOrder[0]!)
+ .toBeGreaterThan(reloadHistory.mock.invocationCallOrder[0]!)
+ expect(queueComposerPrefill).not.toHaveBeenCalled()
+ expect(screen.queryByRole('textbox', { name: 'Edited message' })).toBeNull()
+ })
+
+ it('says how many later turns an older edit deletes, and Cancel changes nothing', async () => {
+ const { sendMessage } = setup(threeTurns, [
+ checkpoint('user-1', 0, 3), checkpoint('user-2', 1, 3), checkpoint('user-3', 2, 3),
+ ])
+ const rewind = vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult())
+ render()
+
+ const textbox = await openEditorFor('First prompt')
+ typeAndSend(textbox, 'First prompt, revised')
+
+ const dialog = await screen.findByRole('dialog', { name: 'Edit and resend?' })
+ expect(within(dialog).getByText('The 2 later turn(s) after this message will be deleted, then the edited message is sent.')).toBeTruthy()
+ expect(within(dialog).getByText('Files on disk will not be changed.')).toBeTruthy()
+
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ expect(rewind).toHaveBeenCalledOnce()
+ expect(rewind.mock.calls[0]![1]).toMatchObject({ dryRun: true })
+ expect(sendMessage).not.toHaveBeenCalled()
+ expect((screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement).value)
+ .toBe('First prompt, revised')
+ expect(screen.getByText('Second prompt')).toBeTruthy()
+ })
+
+ it('restores code and conversation through the authoritative checkpoint when chosen', async () => {
+ // The live bubble has a local id; the server's checkpoint names the
+ // transcript message, and that is the one the rewind must address.
+ const { sendMessage } = setup([
+ { id: 'local-1', type: 'user_text', content: 'Edit the file', timestamp: 1 },
+ { id: 'assistant-1', type: 'assistant_text', content: 'Edited', timestamp: 2 },
+ ], [checkpoint('transcript-1', 0, 1, {
+ code: { available: true, filesChanged: ['src/a.ts'], insertions: 1, deletions: 0 },
+ })])
+ const rewind = vi.spyOn(sessionsApi, 'rewind')
+ .mockResolvedValueOnce(rewindResult({
+ code: { available: true, filesChanged: ['src/a.ts'], insertions: 1, deletions: 0 },
+ }))
+ .mockResolvedValueOnce(rewindResult({
+ code: { available: true, filesChanged: ['src/a.ts'], insertions: 1, deletions: 0 },
+ mode: 'both',
+ }))
+ render()
+
+ const textbox = await openEditorFor('Edit the file')
+ typeAndSend(textbox, 'Edit the other file')
+
+ const dialog = await screen.findByRole('dialog', { name: 'Edit and resend?' })
+ expect(within(dialog).getByText('The reply to this turn will be deleted, then the edited message is sent.')).toBeTruthy()
+ expect(within(dialog).getByRole('button', { name: 'Roll back conversation only and send' })).toBeTruthy()
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Roll back code and conversation and send' }))
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
+ expect(rewind).toHaveBeenCalledTimes(2)
+ expect(rewind).toHaveBeenNthCalledWith(1, ACTIVE_TAB, {
+ targetUserMessageId: 'transcript-1', userMessageIndex: 0, expectedContent: 'Edit the file', dryRun: true,
+ })
+ expect(rewind).toHaveBeenNthCalledWith(2, ACTIVE_TAB, {
+ targetUserMessageId: 'transcript-1', userMessageIndex: 0, expectedContent: 'Edit the file', mode: 'both',
+ })
+ expect(useUIStore.getState().toasts).toEqual([
+ expect.objectContaining({ type: 'success', message: 'Rewound 2 messages and restored tracked files.' }),
+ ])
+ })
+
+ it('offers only the conversation rollback when the files cannot be restored', async () => {
+ const { sendMessage } = setup(oneTurn, [checkpoint('user-1', 0, 1, {
+ code: { available: true, filesChanged: ['src/a.ts'], insertions: 1, deletions: 0 },
+ restoreAvailable: false,
+ })])
+ const rewind = vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult({
+ code: { available: true, filesChanged: ['src/a.ts'], insertions: 1, deletions: 0 },
+ restoreAvailable: false,
+ }))
+ render()
+
+ typeAndSend(await openEditorFor('Build a page'), 'Build a smaller page')
+
+ const dialog = await screen.findByRole('dialog', { name: 'Edit and resend?' })
+ expect(within(dialog).queryByRole('button', { name: 'Roll back code and conversation and send' })).toBeNull()
+ expect(within(dialog).getByText(/incomplete file checkpoint/)).toBeTruthy()
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Roll back conversation only and send' }))
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
+ expect(rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, expect.objectContaining({ mode: 'conversation' }))
+ })
+
+ it('keeps the draft and sends nothing when the rewind itself fails', async () => {
+ const { reloadHistory, sendMessage } = setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ vi.spyOn(sessionsApi, 'rewind')
+ .mockResolvedValueOnce(rewindResult())
+ .mockRejectedValueOnce(new Error('late tool output'))
+ render()
+
+ typeAndSend(await openEditorFor('Build a page'), 'Build a landing page')
+
+ await waitFor(() => expect(useUIStore.getState().toasts).toEqual([
+ expect.objectContaining({
+ type: 'error',
+ message: 'Could not resend the edited message. The conversation was not changed. Details: late tool output',
+ }),
+ ]))
+ expect(reloadHistory).not.toHaveBeenCalled()
+ expect(sendMessage).not.toHaveBeenCalled()
+ expect((screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement).value)
+ .toBe('Build a landing page')
+ })
+
+ it('keeps the draft when the dry run fails', async () => {
+ const { sendMessage } = setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ const rewind = vi.spyOn(sessionsApi, 'rewind').mockRejectedValue(new Error('prompt changed'))
+ render()
+
+ typeAndSend(await openEditorFor('Build a page'), 'Build a landing page')
+
+ await waitFor(() => expect(useUIStore.getState().toasts).toHaveLength(1))
+ expect(rewind).toHaveBeenCalledOnce()
+ expect(sendMessage).not.toHaveBeenCalled()
+ expect((screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement).value)
+ .toBe('Build a landing page')
+ })
+
+ it('hands the edit to the composer when it cannot be sent after the rewind', async () => {
+ const { reloadHistory, sendMessage, queueComposerPrefill } = setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ reloadHistory.mockRejectedValue(new Error('offline'))
+ vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult())
+ render()
+
+ typeAndSend(await openEditorFor('Build a page'), 'Build a landing page')
+
+ await waitFor(() => expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
+ text: 'Build a landing page',
+ attachments: [],
+ }))
+ expect(sendMessage).not.toHaveBeenCalled()
+ expect(useUIStore.getState().toasts).toEqual([
+ expect.objectContaining({ type: 'warning' }),
+ ])
+ })
+
+ it('cancels with Escape without touching the conversation', async () => {
+ setup(oneTurn, [checkpoint('user-1', 0, 1)])
+ const rewind = vi.spyOn(sessionsApi, 'rewind')
+ render()
+
+ const textbox = await openEditorFor('Build a page')
+ fireEvent.change(textbox, { target: { value: 'Never mind' } })
+ fireEvent.keyDown(textbox, { key: 'Escape' })
+
+ expect(screen.queryByRole('textbox', { name: 'Edited message' })).toBeNull()
+ expect(screen.getByText('Build a page')).toBeTruthy()
+ expect(rewind).not.toHaveBeenCalled()
+ })
+
+ it('resends workspace references and images with the edited text', async () => {
+ const referencePrompt = [
+ 'Referenced workspace context:',
+ '@"src/app.ts:L3-L5":',
+ '```typescript',
+ 'for (;;) {}',
+ '```',
+ ].join('\n')
+ const { sendMessage } = setup([
+ {
+ id: 'user-1',
+ type: 'user_text',
+ content: 'Why does this spin?',
+ modelContent: `@"/repo/src/app.ts" ${referencePrompt}\n\nWhy does this spin?`,
+ attachments: [
+ { type: 'file', name: 'app.ts', path: 'src/app.ts', lineStart: 3, lineEnd: 5, quote: 'for (;;) {}' },
+ { type: 'image', name: 'shot.png', data: 'data:image/png;base64,AAAA', mimeType: 'image/png' },
+ ],
+ timestamp: 1,
+ },
+ { id: 'assistant-1', type: 'assistant_text', content: 'Because', timestamp: 2 },
+ ], [checkpoint('user-1', 0, 1)])
+ vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult())
+ render()
+
+ typeAndSend(await openEditorFor('Why does this spin?'), 'How do I stop it?')
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
+ const [, content, attachments, options] = sendMessage.mock.calls[0]!
+ expect(content).toBe(`${referencePrompt}\n\nHow do I stop it?`)
+ expect(attachments).toEqual([
+ expect.objectContaining({ type: 'file', path: 'src/app.ts', lineStart: 3, lineEnd: 5, quote: 'for (;;) {}' }),
+ { type: 'image', name: 'shot.png', mimeType: 'image/png', data: 'data:image/png;base64,AAAA' },
+ ])
+ expect(options).toMatchObject({ displayContent: 'How do I stop it?' })
+ })
+})
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 3de165ae..d68b7732 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -6,9 +6,9 @@ import { ApiError } from '../../api/client'
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint, type WorkspaceChangedFile } from '../../api/sessions'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
+import { useSideChatStore } from '../../stores/sideChatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspaceStore, type WorkspaceOrigin } from '../../stores/workspaceStore'
-import { useWorkspaceReviewStore } from '../../stores/workspaceReviewStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
@@ -32,6 +32,9 @@ import { RenderItemBoundary } from './RenderItemBoundary'
import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
+import { describeRewindResult, getApiErrorMessage, rewindToTurnCheckpoint } from './turnRewind'
+import { useUserMessageEditResend } from './useUserMessageEditResend'
+import type { UserMessageEditAction } from './UserMessage'
import { WorkspaceChangesFallback } from '@/components/chat/WorkspaceChangesFallback'
import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary'
import { MEMBER_AVATARS, memberAccentColor } from '../agentTeams/agentTeamsAvatars'
@@ -1436,16 +1439,6 @@ export function trailingStreamingRailPosition(positions: TurnRailPosition[]): Tu
return last === 'start' || last === 'middle' ? 'end' : 'solo'
}
-function getApiErrorMessage(error: unknown) {
- return error instanceof ApiError
- ? typeof error.body === 'object' && error.body && 'message' in error.body
- ? String((error.body as { message: unknown }).message)
- : error.message
- : error instanceof Error
- ? error.message
- : String(error)
-}
-
function isCheckpointPreviewBudgetError(error: unknown): boolean {
return error instanceof ApiError &&
error.status === 413 &&
@@ -2280,7 +2273,6 @@ export function MessageList({
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
)
const branchSession = useSessionStore((s) => s.branchSession)
- const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const loadOlderHistory = useChatStore((s) => s.loadOlderHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
@@ -2293,6 +2285,7 @@ export function MessageList({
(tab.type === 'subagent' || tab.type === 'team-member')
)))
const isDirectAgentSession = isMemberSession || isAgentRunTab
+ const isSideChatSession = useSideChatStore((s) => Boolean(resolvedSessionId && s.entries[resolvedSessionId]))
const teamWorkbench = useTeamStore((s) =>
resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined,
)
@@ -3201,45 +3194,17 @@ export function MessageList({
})
try {
- if (chatState !== 'idle') {
- stopGeneration(resolvedSessionId)
- }
-
- const checkpointTarget = confirmTurnCard.checkpoint.target
- const result = await sessionsApi.rewind(resolvedSessionId, {
- targetUserMessageId: checkpointTarget.targetUserMessageId,
- userMessageIndex: checkpointTarget.userMessageIndex,
+ const result = await rewindToTurnCheckpoint(resolvedSessionId, {
+ checkpointTarget: confirmTurnCard.checkpoint.target,
expectedContent: target.expectedContent,
- mode,
- })
-
- useWorkspaceStore.getState().pruneTurnReviewTabs(resolvedSessionId, checkpointTarget.userMessageIndex)
- useWorkspaceReviewStore.getState().clearTurnReviews(resolvedSessionId, checkpointTarget.userMessageIndex)
+ }, mode)
await reloadHistory(resolvedSessionId)
queueComposerPrefill(resolvedSessionId, {
text: target.content,
attachments: target.attachments,
})
-
- // Each branch has to match what actually happened on disk: nothing was
- // restored in conversation mode, and in `both` mode a turn that also wrote
- // off-checkpoint left changes behind. A plain success would overstate both.
- const messageCount = result.conversation.messagesRemoved
- const leftBehind = mode === 'both' ? result.unverifiedChangeSources ?? [] : []
- addToast({
- type: leftBehind.length > 0 ? 'warning' : 'success',
- message: mode === 'conversation'
- ? t('chat.rewindSuccessConversationOnly', { count: messageCount })
- : leftBehind.length > 0
- ? t('chat.rewindSuccessPartialCoverage', {
- count: messageCount,
- sources: leftBehind.join(', '),
- })
- : result.code.available
- ? t('chat.rewindSuccessWithCode', { count: messageCount })
- : t('chat.rewindSuccessConversationOnly', { count: messageCount }),
- })
+ addToast(describeRewindResult(result, mode, t))
setTurnUndoConfirmTargetId(null)
} catch (error) {
@@ -3253,14 +3218,12 @@ export function MessageList({
}
}, [
addToast,
- chatState,
confirmTurnCard,
hasRunningBackgroundTasks,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
rewindingTurnId,
- stopGeneration,
t,
])
@@ -3332,6 +3295,16 @@ export function MessageList({
return result
}, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t])
+ const { editActionByMessageId, dialog: editResendDialog } = useUserMessageEditResend({
+ sessionId: resolvedSessionId,
+ messages,
+ turnCards: visibleTurnChangeCards,
+ disabled: branchActionsDisabled || isSideChatSession || Boolean(turnUndoConfirmTargetId),
+ rewindingTurnId,
+ setRewindingTurnId,
+ t,
+ })
+
const toolResultByToolUseId = useMemo(() => {
if (toolResultMap.size === 0) return new Map()
const result = new Map()
@@ -3647,6 +3620,7 @@ export function MessageList({
: null
}
branchAction={branchActionByMessageId.get(item.message.id)}
+ editAction={editActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
@@ -3871,6 +3845,15 @@ export function MessageList({
width={520}
loading={Boolean(rewindingTurnId)}
/>
+
)
}
@@ -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}
+
+ )
+}
diff --git a/desktop/src/components/chat/turnRewind.ts b/desktop/src/components/chat/turnRewind.ts
new file mode 100644
index 00000000..0f01a2a9
--- /dev/null
+++ b/desktop/src/components/chat/turnRewind.ts
@@ -0,0 +1,81 @@
+import {
+ sessionsApi,
+ type SessionRewindMode,
+ type SessionRewindResponse,
+ type SessionTurnCheckpoint,
+} from '../../api/sessions'
+import { ApiError } from '../../api/client'
+import type { TranslationKey } from '../../i18n/locales/en'
+import { useChatStore } from '../../stores/chatStore'
+import { useWorkspaceReviewStore } from '../../stores/workspaceReviewStore'
+import { useWorkspaceStore } from '../../stores/workspaceStore'
+
+type Translate = (key: TranslationKey, params?: Record) => string
+
+/**
+ * The one way the chat surface rewinds a turn. Both "undo this turn" and
+ * "edit and resend" go through it, so a rewind always stops the live turn
+ * first, addresses the server's authoritative checkpoint target, and drops the
+ * review state of the turns it removed. Reloading history and what happens to
+ * the prompt afterwards are the caller's business.
+ */
+export async function rewindToTurnCheckpoint(
+ sessionId: string,
+ request: {
+ checkpointTarget: SessionTurnCheckpoint['target']
+ expectedContent: string
+ },
+ mode: SessionRewindMode,
+): Promise {
+ const chatStore = useChatStore.getState()
+ if ((chatStore.sessions[sessionId]?.chatState ?? 'idle') !== 'idle') {
+ chatStore.stopGeneration(sessionId)
+ }
+
+ const { checkpointTarget } = request
+ const result = await sessionsApi.rewind(sessionId, {
+ targetUserMessageId: checkpointTarget.targetUserMessageId,
+ userMessageIndex: checkpointTarget.userMessageIndex,
+ expectedContent: request.expectedContent,
+ mode,
+ })
+
+ useWorkspaceStore.getState().pruneTurnReviewTabs(sessionId, checkpointTarget.userMessageIndex)
+ useWorkspaceReviewStore.getState().clearTurnReviews(sessionId, checkpointTarget.userMessageIndex)
+ return result
+}
+
+/**
+ * Each branch has to match what actually happened on disk: nothing was
+ * restored in conversation mode, and in `both` mode a turn that also wrote
+ * off-checkpoint left changes behind. A plain success would overstate both.
+ */
+export function describeRewindResult(
+ result: SessionRewindResponse,
+ mode: SessionRewindMode,
+ t: Translate,
+): { type: 'success' | 'warning'; message: string } {
+ const count = result.conversation.messagesRemoved
+ const leftBehind = mode === 'both' ? result.unverifiedChangeSources ?? [] : []
+ return {
+ type: leftBehind.length > 0 ? 'warning' : 'success',
+ message: mode === 'conversation'
+ ? t('chat.rewindSuccessConversationOnly', { count })
+ : leftBehind.length > 0
+ ? t('chat.rewindSuccessPartialCoverage', { count, sources: leftBehind.join(', ') })
+ : result.code.available
+ ? t('chat.rewindSuccessWithCode', { count })
+ : t('chat.rewindSuccessConversationOnly', { count }),
+ }
+}
+
+/** The server's own explanation when it gave one, otherwise the error text. */
+export function getApiErrorMessage(error: unknown): string {
+ return error instanceof ApiError
+ ? typeof error.body === 'object' && error.body && 'message' in error.body
+ ? String((error.body as { message: unknown }).message)
+ : error.message
+ : error instanceof Error
+ ? error.message
+ : String(error)
+}
diff --git a/desktop/src/components/chat/useUserMessageEditResend.tsx b/desktop/src/components/chat/useUserMessageEditResend.tsx
new file mode 100644
index 00000000..0f869ba3
--- /dev/null
+++ b/desktop/src/components/chat/useUserMessageEditResend.tsx
@@ -0,0 +1,302 @@
+import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
+import { sessionsApi, type SessionRewindMode, type SessionRewindResponse, type SessionTurnCheckpoint } from '../../api/sessions'
+import type { ActionDialogAction } from '@/components/ui/ActionDialog'
+import type { TranslationKey } from '../../i18n/locales/en'
+import { useChatStore } from '../../stores/chatStore'
+import { useUIStore } from '../../stores/uiStore'
+import type { UIMessage } from '../../types/chat'
+import { describeRewindResult, getApiErrorMessage, rewindToTurnCheckpoint } from './turnRewind'
+import type { UserMessageEditAction } from './UserMessage'
+import {
+ buildUserMessageResendPayload,
+ countLaterUserTurns,
+ createUserMessageEditDraft,
+ type UserMessageEditDraft,
+ type UserMessageResendPayload,
+} from './userMessageEdit'
+
+type Translate = (key: TranslationKey, params?: Record) => string
+
+/** The slice of a turn card the edit flow needs: who to rewind, and how. */
+export type EditableTurnCard = {
+ target: { messageId: string; expectedContent: string }
+ checkpoint: { target: SessionTurnCheckpoint['target'] }
+}
+
+type PendingConfirm = {
+ messageId: string
+ card: EditableTurnCard
+ draft: UserMessageEditDraft
+ payload: UserMessageResendPayload
+ preview: SessionRewindResponse
+ laterTurns: number
+ canRestoreCode: boolean
+}
+
+type Options = {
+ sessionId: string | null | undefined
+ messages: UIMessage[]
+ turnCards: EditableTurnCard[]
+ /** Busy, background work, an agent tab, a side chat: no new edits start. */
+ disabled: boolean
+ rewindingTurnId: string | null
+ setRewindingTurnId: (messageId: string | null) => void
+ t: Translate
+}
+
+export type UserMessageEditDialog = {
+ open: boolean
+ title: string
+ body: ReactNode
+ actions: ActionDialogAction[]
+ loading: boolean
+ onClose: () => void
+}
+
+function isEditableUserMessage(message: UIMessage | undefined): message is Extract {
+ return Boolean(
+ message &&
+ message.type === 'user_text' &&
+ !message.pending &&
+ !message.optimisticQueued &&
+ !message.teammateFrom &&
+ !message.collaboration,
+ )
+}
+
+/**
+ * Edit a prompt in place, then rewind to before it and send the edit.
+ *
+ * Only turns the rewind API can already target are editable, and the rewind is
+ * the existing one: a dry run first, so the dialog describes the same range the
+ * real rewind will touch, then `conversation` or `both` exactly as "undo this
+ * turn" does. Until the rewind succeeds nothing has changed and the editor
+ * keeps the draft; after it succeeds the edit is sent, or handed to the
+ * composer if it cannot be.
+ */
+export function useUserMessageEditResend({
+ sessionId,
+ messages,
+ turnCards,
+ disabled,
+ rewindingTurnId,
+ setRewindingTurnId,
+ t,
+}: Options) {
+ const [editingMessageId, setEditingMessageId] = useState(null)
+ const [submittingMessageId, setSubmittingMessageId] = useState(null)
+ const [confirm, setConfirm] = useState(null)
+ const draftsRef = useRef(new Map())
+ const messagesRef = useRef(messages)
+ messagesRef.current = messages
+
+ const messageById = useMemo(() => {
+ const result = new Map>()
+ for (const message of messages) {
+ if (message.type === 'user_text') result.set(message.id, message)
+ }
+ return result
+ }, [messages])
+
+ const editableCards = useMemo(() => {
+ const result = new Map()
+ for (const card of turnCards) {
+ if (isEditableUserMessage(messageById.get(card.target.messageId))) {
+ result.set(card.target.messageId, card)
+ }
+ }
+ return result
+ }, [messageById, turnCards])
+
+ const contextReferencesOnly = useCallback(
+ (count: number) => t('chat.contextReferencesOnly', { count }),
+ [t],
+ )
+
+ const closeEditor = useCallback((messageId: string) => {
+ draftsRef.current.delete(messageId)
+ setEditingMessageId((current) => current === messageId ? null : current)
+ }, [])
+
+ const runResend = useCallback(async (
+ messageId: string,
+ card: EditableTurnCard,
+ draft: UserMessageEditDraft,
+ payload: UserMessageResendPayload,
+ mode: SessionRewindMode,
+ ) => {
+ if (!sessionId) return
+ const addToast = useUIStore.getState().addToast
+ setRewindingTurnId(messageId)
+ let result: SessionRewindResponse
+ try {
+ result = await rewindToTurnCheckpoint(sessionId, {
+ checkpointTarget: card.checkpoint.target,
+ expectedContent: card.target.expectedContent,
+ }, mode)
+ } catch (error) {
+ // The server rewinds atomically: a failed rewind left the conversation
+ // and the files as they were, so the draft stays open for another try.
+ addToast({ type: 'error', message: t('chat.editResendFailed', { detail: getApiErrorMessage(error) }) })
+ setConfirm(null)
+ setRewindingTurnId(null)
+ return
+ }
+
+ // From here the old turn is gone. The edit must reach either the model or
+ // the composer — never be dropped with the editor.
+ closeEditor(messageId)
+ setConfirm(null)
+ const chatStore = useChatStore.getState()
+ let sent = false
+ try {
+ await chatStore.reloadHistory(sessionId)
+ if ((useChatStore.getState().sessions[sessionId]?.chatState ?? 'idle') === 'idle') {
+ useChatStore.getState().sendMessage(sessionId, payload.content, payload.attachments, payload.options)
+ sent = true
+ }
+ } catch {
+ sent = false
+ }
+ if (!sent) {
+ useChatStore.getState().queueComposerPrefill(sessionId, {
+ text: draft.text,
+ attachments: draft.attachments.filter((attachment) => attachment.sendable),
+ })
+ addToast({ type: 'warning', message: t('chat.editResendPrefilled') })
+ } else if (mode === 'both') {
+ addToast(describeRewindResult(result, mode, t))
+ }
+ setRewindingTurnId(null)
+ }, [closeEditor, sessionId, setRewindingTurnId, t])
+
+ const submit = useCallback(async (messageId: string, draft: UserMessageEditDraft) => {
+ const card = editableCards.get(messageId)
+ if (!sessionId || !card || disabled || submittingMessageId || rewindingTurnId) return
+ const payload = buildUserMessageResendPayload(draft, { contextReferencesOnly })
+ if (!payload) return
+ draftsRef.current.set(messageId, draft)
+
+ setSubmittingMessageId(messageId)
+ let preview: SessionRewindResponse
+ try {
+ preview = await sessionsApi.rewind(sessionId, {
+ targetUserMessageId: card.checkpoint.target.targetUserMessageId,
+ userMessageIndex: card.checkpoint.target.userMessageIndex,
+ expectedContent: card.target.expectedContent,
+ dryRun: true,
+ })
+ } catch (error) {
+ useUIStore.getState().addToast({
+ type: 'error',
+ message: t('chat.editResendFailed', { detail: getApiErrorMessage(error) }),
+ })
+ setSubmittingMessageId(null)
+ return
+ }
+ setSubmittingMessageId(null)
+
+ const canRestoreCode = preview.code.available &&
+ preview.code.filesChanged.length > 0 &&
+ preview.restoreAvailable !== false
+ const laterTurns = countLaterUserTurns(messagesRef.current, messageId)
+ // Nothing on disk to decide about and nothing beyond this turn to lose:
+ // the edit is the whole consequence, so it goes straight through.
+ if (!canRestoreCode && preview.restoreAvailable !== false && laterTurns === 0) {
+ await runResend(messageId, card, draft, payload, 'conversation')
+ return
+ }
+ setConfirm({ messageId, card, draft, payload, preview, laterTurns, canRestoreCode })
+ }, [contextReferencesOnly, disabled, editableCards, rewindingTurnId, runResend, sessionId, submittingMessageId, t])
+
+ const editActionByMessageId = useMemo(() => {
+ const result = new Map()
+ if (!sessionId) return result
+ const label = t('chat.editMessage')
+ for (const [messageId] of editableCards) {
+ const editing = editingMessageId === messageId
+ // A disabled session offers no new edits, but an open editor stays open
+ // (disabled) instead of discarding what the user typed.
+ if (disabled && !editing) continue
+ const message = messageById.get(messageId)!
+ result.set(messageId, {
+ label,
+ editing,
+ submitting: submittingMessageId === messageId || rewindingTurnId === messageId,
+ disabled: disabled || Boolean(rewindingTurnId && rewindingTurnId !== messageId),
+ getDraft: () => draftsRef.current.get(messageId) ?? createUserMessageEditDraft(message),
+ onStart: () => {
+ setEditingMessageId((current) => {
+ if (current && current !== messageId) draftsRef.current.delete(current)
+ return messageId
+ })
+ },
+ onCancel: () => closeEditor(messageId),
+ onDraftChange: (draft) => { draftsRef.current.set(messageId, draft) },
+ onSubmit: (draft) => { void submit(messageId, draft) },
+ })
+ }
+ return result
+ }, [closeEditor, disabled, editableCards, editingMessageId, messageById, rewindingTurnId, sessionId, submit, submittingMessageId, t])
+
+ const dialog = useMemo(() => {
+ const busy = Boolean(rewindingTurnId)
+ const close = () => { if (!busy) setConfirm(null) }
+ if (!confirm) {
+ return { open: false, title: '', body: null, actions: [], loading: false, onClose: close }
+ }
+ const { preview, laterTurns, canRestoreCode } = confirm
+ const unverified = preview.unverifiedChangeSources ?? []
+ const lines = [
+ laterTurns > 0
+ ? t('chat.editResendDropsTurns', { count: laterTurns })
+ : t('chat.editResendLatestBody'),
+ ]
+ let caution: string | null = null
+ if (canRestoreCode) {
+ lines.push(t('chat.editResendRestoreChoice', { count: preview.code.filesChanged.length }))
+ if (unverified.length > 0) {
+ caution = t('chat.turnChangesPartialCoverageConfirmBody', { sources: unverified.join(', ') })
+ }
+ } else if (preview.restoreAvailable === false) {
+ caution = t('chat.turnChangesConversationOnlyConfirmBody')
+ } else {
+ lines.push(t('chat.editResendFilesUntouched'))
+ }
+ const body = (
+
+ {lines.map((line, index) =>
{line}
)}
+ {caution ?
{caution}
: null}
+
+ )
+ const run = (mode: SessionRewindMode) => {
+ void runResend(confirm.messageId, confirm.card, confirm.draft, confirm.payload, mode)
+ }
+ const actions: ActionDialogAction[] = [
+ { label: t('common.cancel'), onClick: close, variant: 'secondary' },
+ canRestoreCode
+ ? { label: t('chat.editResendConversationOnly'), onClick: () => run('conversation'), variant: 'secondary', loading: busy }
+ : {
+ label: preview.restoreAvailable === false
+ ? t('chat.editResendConversationOnly')
+ : t('chat.editResendConversation'),
+ onClick: () => run('conversation'),
+ variant: 'danger',
+ loading: busy,
+ },
+ ...(canRestoreCode
+ ? [{ label: t('chat.editResendWithCode'), onClick: () => run('both'), variant: 'danger' as const, loading: busy }]
+ : []),
+ ]
+ return {
+ open: true,
+ title: t('chat.editResendConfirmTitle'),
+ body,
+ actions,
+ loading: busy,
+ onClose: close,
+ }
+ }, [confirm, rewindingTurnId, runResend, t])
+
+ return { editActionByMessageId, dialog }
+}
diff --git a/desktop/src/components/chat/userMessageEdit.test.ts b/desktop/src/components/chat/userMessageEdit.test.ts
new file mode 100644
index 00000000..1ba66688
--- /dev/null
+++ b/desktop/src/components/chat/userMessageEdit.test.ts
@@ -0,0 +1,223 @@
+import { describe, expect, it } from 'vitest'
+import type { UIMessage } from '../../types/chat'
+import {
+ buildUserMessageResendPayload,
+ countLaterUserTurns,
+ createUserMessageEditDraft,
+ type UserMessageEditDraft,
+} from './userMessageEdit'
+
+const labels = { contextReferencesOnly: (count: number) => `Added ${count} references` }
+
+const WORKSPACE_PROMPT = [
+ 'Referenced workspace context:',
+ '@"src/app.ts:L3-L5":',
+ 'Comment: why does this loop',
+ '```typescript',
+ 'for (;;) {}',
+ '```',
+].join('\n')
+
+describe('createUserMessageEditDraft', () => {
+ it('edits the plain prompt of a message with nothing attached', () => {
+ const draft = createUserMessageEditDraft({ content: 'Fix the login bug' })
+
+ expect(draft).toEqual({ text: 'Fix the login bug', attachments: [], sessionReferences: [] })
+ })
+
+ it('turns the workspace reference prompt into a chip and keeps only the typed text', () => {
+ // What the composer sends for a code selection: the server prefixes the
+ // absolute path, then the reference block, then what the user typed.
+ const draft = createUserMessageEditDraft({
+ content: 'Please refactor',
+ modelContent: `@"/repo/src/app.ts" ${WORKSPACE_PROMPT}\n\nPlease refactor`,
+ })
+
+ expect(draft.text).toBe('Please refactor')
+ expect(draft.attachments).toEqual([
+ expect.objectContaining({
+ type: 'file',
+ path: 'src/app.ts',
+ lineStart: 3,
+ lineEnd: 5,
+ note: 'why does this loop',
+ quote: 'for (;;) {}',
+ sendable: true,
+ }),
+ ])
+ })
+
+ it('keeps inline image data from the message and drops the generated image metadata line', () => {
+ const draft = createUserMessageEditDraft({
+ content: 'What is wrong here?',
+ modelContent: 'What is wrong here?\n[Image source: /tmp/uploads/shot.png]',
+ attachments: [{ type: 'image', name: 'shot.png', data: 'data:image/png;base64,AAAA', path: '/tmp/uploads/shot.png' }],
+ })
+
+ expect(draft.text).toBe('What is wrong here?')
+ expect(draft.attachments).toEqual([
+ expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA', sendable: true }),
+ ])
+ })
+
+ it('moves the session-reference envelope out of the text', () => {
+ const draft = createUserMessageEditDraft({
+ content: 'Compare with that session',
+ modelContent: 'Compare with that session\n\n\nReferenced sessions:\n[{"sessionId":"session-b"}]\n',
+ })
+
+ expect(draft.text).toBe('Compare with that session')
+ expect(draft.sessionReferences).toEqual([{ sessionId: 'session-b' }])
+ })
+
+ it('does not bring back the placeholder written for an attachment-only prompt', () => {
+ const draft = createUserMessageEditDraft({
+ content: '',
+ modelContent: '@"/repo/notes.md" Please analyze the attached files.',
+ })
+
+ expect(draft.text).toBe('')
+ expect(draft.attachments).toEqual([expect.objectContaining({ type: 'file', path: '/repo/notes.md' })])
+ })
+
+ it('keeps a chat selection once, in the text the model saw, not again as a chip', () => {
+ const chatBlock = 'Referenced chat context:\nAssistant message:\n```\nuse a mutex\n```'
+ const draft = createUserMessageEditDraft({
+ content: 'Why?',
+ modelContent: `${chatBlock}\n\nWhy?`,
+ attachments: [{ type: 'file', name: 'Assistant message', referenceKind: 'chat-selection', quote: 'use a mutex' }],
+ })
+
+ expect(draft.text).toBe(`${chatBlock}\n\nWhy?`)
+ expect(draft.attachments).toEqual([])
+ })
+
+ it('marks an attachment with neither data nor a path as not sendable', () => {
+ const draft = createUserMessageEditDraft({
+ content: 'Summarize',
+ attachments: [{ type: 'file', name: 'report.pdf' }],
+ })
+
+ expect(draft.attachments).toEqual([expect.objectContaining({ name: 'report.pdf', sendable: false })])
+ })
+})
+
+describe('buildUserMessageResendPayload', () => {
+ it('sends the edited text alone when nothing is attached', () => {
+ const payload = buildUserMessageResendPayload(
+ { text: ' Fix the signup bug ', attachments: [], sessionReferences: [] },
+ labels,
+ )
+
+ expect(payload).toEqual({
+ content: 'Fix the signup bug',
+ attachments: [],
+ options: { displayContent: 'Fix the signup bug', displayAttachments: [] },
+ droppedAttachmentCount: 0,
+ })
+ })
+
+ it('rebuilds the reference prompt in front of the edited text and keeps the file attached', () => {
+ const draft = createUserMessageEditDraft({
+ content: 'Please refactor',
+ modelContent: `@"/repo/src/app.ts" ${WORKSPACE_PROMPT}\n\nPlease refactor`,
+ })
+ const payload = buildUserMessageResendPayload({ ...draft, text: 'Please delete it instead' }, labels)!
+
+ expect(payload.content).toBe(`${WORKSPACE_PROMPT}\n\nPlease delete it instead`)
+ expect(payload.attachments).toEqual([
+ expect.objectContaining({ type: 'file', path: 'src/app.ts', lineStart: 3, lineEnd: 5 }),
+ ])
+ expect(payload.options.displayContent).toBe('Please delete it instead')
+
+ // The resent prompt must read back as the same draft, or a second edit of
+ // the replacement would drift.
+ const reread = createUserMessageEditDraft({
+ content: 'Please delete it instead',
+ modelContent: `@"src/app.ts" ${payload.content}`,
+ })
+ expect(reread.text).toBe('Please delete it instead')
+ expect(reread.attachments.map(({ id: _id, ...rest }) => rest))
+ .toEqual(draft.attachments.map(({ id: _id, ...rest }) => rest))
+ })
+
+ it('drops the reference prompt with its chip when the user removes it', () => {
+ const draft = createUserMessageEditDraft({
+ content: 'Please refactor',
+ modelContent: `@"/repo/src/app.ts" ${WORKSPACE_PROMPT}\n\nPlease refactor`,
+ })
+ const payload = buildUserMessageResendPayload({ ...draft, attachments: [] }, labels)!
+
+ expect(payload.content).toBe('Please refactor')
+ expect(payload.attachments).toEqual([])
+ })
+
+ it('sends an image by its data and not by its upload path', () => {
+ const payload = buildUserMessageResendPayload({
+ text: 'Now?',
+ attachments: [{ id: 'a', type: 'image', name: 'shot.png', data: 'data:image/png;base64,AAAA', path: '/tmp/shot.png', mimeType: 'image/png', sendable: true }],
+ sessionReferences: [],
+ }, labels)!
+
+ expect(payload.attachments).toEqual([
+ { type: 'image', name: 'shot.png', mimeType: 'image/png', data: 'data:image/png;base64,AAAA' },
+ ])
+ })
+
+ it('carries session references as an option, not inside the text', () => {
+ const payload = buildUserMessageResendPayload(
+ { text: 'Compare', attachments: [], sessionReferences: [{ sessionId: 'session-b' }] },
+ labels,
+ )!
+
+ expect(payload.content).toBe('Compare')
+ expect(payload.options.sessionReferences).toEqual([{ sessionId: 'session-b' }])
+ })
+
+ it('leaves unreadable attachments out and reports how many', () => {
+ const draft: UserMessageEditDraft = {
+ text: '',
+ attachments: [
+ { id: 'a', type: 'file', name: 'gone.pdf', sendable: false },
+ { id: 'b', type: 'file', name: 'notes.md', path: '/repo/notes.md', sendable: true },
+ ],
+ sessionReferences: [],
+ }
+ const payload = buildUserMessageResendPayload(draft, labels)!
+
+ expect(payload.attachments).toEqual([expect.objectContaining({ path: '/repo/notes.md' })])
+ expect(payload.options.displayContent).toBe('Added 1 references')
+ expect(payload.droppedAttachmentCount).toBe(1)
+ })
+
+ it('returns nothing to send for an empty draft', () => {
+ expect(buildUserMessageResendPayload({ text: ' ', attachments: [], sessionReferences: [] }, labels)).toBeNull()
+ expect(buildUserMessageResendPayload({
+ text: '',
+ attachments: [{ id: 'a', type: 'file', name: 'gone.pdf', sendable: false }],
+ sessionReferences: [],
+ }, labels)).toBeNull()
+ })
+})
+
+describe('countLaterUserTurns', () => {
+ const user = (id: string, extra: Partial> = {}): UIMessage => ({
+ id, type: 'user_text', content: id, timestamp: 0, ...extra,
+ })
+ const reply = (id: string): UIMessage => ({ id, type: 'assistant_text', content: id, timestamp: 0 })
+
+ it('counts only the real user turns after the target', () => {
+ const messages = [
+ user('u1'), reply('a1'),
+ user('u2'), reply('a2'),
+ user('teammate', { teammateFrom: 'reviewer' }),
+ user('u3'), reply('a3'),
+ user('queued', { optimisticQueued: true }),
+ user('pending', { pending: true }),
+ ]
+
+ expect(countLaterUserTurns(messages, 'u1')).toBe(2)
+ expect(countLaterUserTurns(messages, 'u3')).toBe(0)
+ expect(countLaterUserTurns(messages, 'missing')).toBe(0)
+ })
+})
diff --git a/desktop/src/components/chat/userMessageEdit.ts b/desktop/src/components/chat/userMessageEdit.ts
new file mode 100644
index 00000000..cd4aaf7e
--- /dev/null
+++ b/desktop/src/components/chat/userMessageEdit.ts
@@ -0,0 +1,192 @@
+import {
+ extractRestoredUserDisplay,
+ pathsReferToSameFile,
+ stripGeneratedImageMetadataLines,
+} from '../../stores/chatStore'
+import {
+ formatWorkspaceReferencePrompt,
+ type WorkspaceChatReference,
+} from '../../stores/workspaceChatContextStore'
+import type { AttachmentRef, UIAttachment, UIMessage } from '../../types/chat'
+
+type UserTextMessage = Extract
+
+export type EditableAttachment = UIAttachment & {
+ id: string
+ /** False when neither inline data nor a path survived; it cannot be resent. */
+ sendable: boolean
+}
+
+export type UserMessageEditDraft = {
+ text: string
+ attachments: EditableAttachment[]
+ sessionReferences: Array<{ sessionId: string }>
+}
+
+export type UserMessageResendPayload = {
+ content: string
+ attachments: AttachmentRef[]
+ options: {
+ sessionReferences?: Array<{ sessionId: string }>
+ displayContent: string
+ displayAttachments: AttachmentRef[]
+ }
+ droppedAttachmentCount: number
+}
+
+// Placeholders the send path writes when a prompt carries attachments but no
+// text (`buildModelContent` and the server's image-only replay). They are not
+// something the user typed, so they must not come back into the editor.
+const ATTACHMENT_ONLY_PLACEHOLDERS = new Set([
+ 'Please analyze the attached files.',
+ 'Please analyze the attached image.',
+])
+
+function isSendable(attachment: UIAttachment): boolean {
+ return Boolean(attachment.data || attachment.path)
+}
+
+/**
+ * Rebuild what the user originally typed from the prompt that actually reached
+ * the model. Parsing the model-facing text with the same reader history uses
+ * makes a live bubble and its reloaded copy produce the same draft: leading
+ * `@"path"` references and the workspace reference block become chips, the
+ * session-reference envelope moves to `sessionReferences`, and the remaining
+ * text is the prompt body.
+ */
+export function createUserMessageEditDraft(
+ message: Pick,
+): UserMessageEditDraft {
+ const messageAttachments = message.attachments ?? []
+ const hasImage = messageAttachments.some((attachment) => attachment.type === 'image')
+ const source = message.modelContent ?? message.content
+ const parsed = extractRestoredUserDisplay(hasImage ? stripGeneratedImageMetadataLines(source) : source)
+ const parsedAttachments = parsed.attachments ?? []
+
+ // Message attachments add what the model text cannot carry — inline images
+ // and data-only uploads. Chat selections are already part of the text body,
+ // and anything with a path the text already referenced would be a duplicate.
+ const extraAttachments = messageAttachments.filter((attachment) =>
+ attachment.referenceKind !== 'chat-selection' &&
+ !parsedAttachments.some((candidate) => attachment.path && pathsReferToSameFile(candidate.path, attachment.path)),
+ )
+
+ const attachments = [...parsedAttachments, ...extraAttachments].map((attachment, index) => ({
+ ...attachment,
+ id: `edit-attachment-${index}`,
+ sendable: isSendable(attachment),
+ }))
+
+ const body = parsed.content.trim()
+ const text = attachments.length > 0 && ATTACHMENT_ONLY_PLACEHOLDERS.has(body) ? '' : body
+
+ return {
+ text,
+ attachments,
+ sessionReferences: message.sessionReferences ?? parsed.sessionReferences ?? [],
+ }
+}
+
+function hasReferenceContext(attachment: UIAttachment): boolean {
+ return Boolean(
+ attachment.lineStart ||
+ attachment.note?.trim() ||
+ attachment.quote?.trim() ||
+ attachment.diffSide ||
+ attachment.hunkId,
+ )
+}
+
+function toWorkspaceReference(attachment: EditableAttachment): WorkspaceChatReference {
+ return {
+ id: attachment.id,
+ kind: attachment.diffSide || attachment.hunkId
+ ? 'code-comment'
+ : attachment.lineStart
+ ? 'code-selection'
+ : 'file',
+ path: attachment.path!,
+ name: attachment.name,
+ isDirectory: attachment.isDirectory,
+ lineStart: attachment.lineStart,
+ lineEnd: attachment.lineEnd,
+ diffSide: attachment.diffSide,
+ hunkId: attachment.hunkId,
+ note: attachment.note,
+ quote: attachment.quote,
+ }
+}
+
+/**
+ * Turn an edited draft back into `sendMessage` arguments, in the same shape the
+ * composer produces (`ChatInput` handleSubmit): the workspace reference prompt
+ * goes in front of the text, and every file keeps travelling as an attachment
+ * so the server still prefixes its `@"path"`.
+ */
+export function buildUserMessageResendPayload(
+ draft: UserMessageEditDraft,
+ labels: { contextReferencesOnly: (count: number) => string },
+): UserMessageResendPayload | null {
+ const text = draft.text.trim()
+ const sendable = draft.attachments.filter((attachment) => attachment.sendable)
+ if (!text && sendable.length === 0) return null
+
+ const referencePrompt = formatWorkspaceReferencePrompt(
+ sendable
+ .filter((attachment) => attachment.type === 'file' && attachment.path && hasReferenceContext(attachment))
+ .map(toWorkspaceReference),
+ )
+ const content = [referencePrompt, text].filter(Boolean).join('\n\n')
+
+ const attachments: AttachmentRef[] = sendable.map((attachment) =>
+ attachment.type === 'image'
+ ? {
+ type: 'image',
+ name: attachment.name,
+ mimeType: attachment.mimeType,
+ ...(attachment.data ? { data: attachment.data } : { path: attachment.path }),
+ }
+ : {
+ type: 'file',
+ name: attachment.name,
+ path: attachment.path,
+ data: attachment.data,
+ mimeType: attachment.mimeType,
+ isDirectory: attachment.isDirectory,
+ lineStart: attachment.lineStart,
+ lineEnd: attachment.lineEnd,
+ note: attachment.note,
+ quote: attachment.quote,
+ },
+ )
+
+ const displayAttachments: AttachmentRef[] = sendable.map(({ id: _id, sendable: _sendable, ...attachment }) => attachment)
+
+ return {
+ content,
+ attachments,
+ options: {
+ ...(draft.sessionReferences.length > 0 ? { sessionReferences: draft.sessionReferences } : {}),
+ displayContent: text || labels.contextReferencesOnly(sendable.length),
+ displayAttachments,
+ },
+ droppedAttachmentCount: draft.attachments.length - sendable.length,
+ }
+}
+
+/** User turns after `messageId` that a rewind to it would delete. */
+export function countLaterUserTurns(messages: UIMessage[], messageId: string): number {
+ const targetIndex = messages.findIndex((message) => message.id === messageId)
+ if (targetIndex < 0) return 0
+ let count = 0
+ for (let index = targetIndex + 1; index < messages.length; index += 1) {
+ const message = messages[index]!
+ if (
+ message.type === 'user_text' &&
+ !message.pending &&
+ !message.optimisticQueued &&
+ !message.teammateFrom
+ ) count += 1
+ }
+ return count
+}
diff --git a/desktop/src/components/ui/TextArea.test.tsx b/desktop/src/components/ui/TextArea.test.tsx
index 9fc88f82..0d46d3c1 100644
--- a/desktop/src/components/ui/TextArea.test.tsx
+++ b/desktop/src/components/ui/TextArea.test.tsx
@@ -1,3 +1,4 @@
+import { createRef } from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it } from 'vitest'
@@ -51,4 +52,12 @@ describe('TextArea', () => {
rerender()
expect(screen.getByLabelText('Prompt')).toHaveAttribute('rows', '10')
})
+
+ // The inline message editor focuses the field and moves the caret to the end
+ // on open; without forwarding, a ref on a function component is dropped.
+ it('forwards its ref to the textarea element', () => {
+ const ref = createRef()
+ render()
+ expect(ref.current).toBe(screen.getByLabelText('Prompt'))
+ })
})
diff --git a/desktop/src/components/ui/TextArea.tsx b/desktop/src/components/ui/TextArea.tsx
index 20baa078..f3d496dc 100644
--- a/desktop/src/components/ui/TextArea.tsx
+++ b/desktop/src/components/ui/TextArea.tsx
@@ -1,4 +1,4 @@
-import { useId, type ReactNode, type TextareaHTMLAttributes } from 'react'
+import { forwardRef, useId, type ReactNode, type TextareaHTMLAttributes } from 'react'
import { cx } from '@/lib/cx'
import { FIELD_BASE_CLASSES, fieldStateClasses } from './Input'
@@ -17,8 +17,11 @@ export type TextAreaProps = TextareaHTMLAttributes & {
* All 13 textareas in the app were unlabeled and had no id, so clicking their
* label — where one existed — did not focus them, and a screen reader
* encountered them as anonymous edit boxes.
+ *
+ * Forwards its ref to the `