feat(desktop): edit a sent prompt and rerun from there (#1343)

Hovering a prompt the rewind API can already target now offers "Edit and
resend". The bubble turns into an inline editor; sending dry-runs the
existing rewind, confirms when later turns or restorable files are at
stake, rewinds with the same conversation/both modes as undo, reloads
history and sends the edited prompt. A failed rewind changes nothing and
keeps the draft; if the edit cannot be sent after a successful rewind it
is handed back to the composer.

Undo and edit share one rewind routine, the unused per-message
rewindAction prop is removed, and TextArea forwards its ref.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-03 23:10:02 +08:00
committed by 程序员阿江-Relakkes
parent 54a77db95d
commit 613296dbdb
21 changed files with 1688 additions and 76 deletions
@@ -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 (
<div
@@ -100,20 +100,20 @@ export function MessageActionBar({
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{rewindAction ? (
{editAction ? (
<IconButton
icon={<Undo2 size={13} strokeWidth={2.2} aria-hidden="true" />}
label={rewindAction.label}
icon={<Pencil size={13} strokeWidth={2.2} aria-hidden="true" />}
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 ? (
<span className={hasCopy || branchAction ? 'ml-3 min-w-0' : 'min-w-0'}>
<span className={hasCopy || branchAction || editAction ? 'ml-3 min-w-0' : 'min-w-0'}>
{metadata}
</span>
) : null}
@@ -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<ReturnType<typeof sessionsApi.getTurnCheckpoints>>['checkpoints'][number]
type RewindResult = Awaited<ReturnType<typeof sessionsApi.rewind>>
function checkpoint(
targetUserMessageId: string,
userMessageIndex: number,
userMessageCount: number,
overrides: Partial<Checkpoint> = {},
): Checkpoint {
return {
target: { targetUserMessageId, userMessageIndex, userMessageCount },
code: { available: false, filesChanged: [], insertions: 0, deletions: 0 },
...overrides,
}
}
function rewindResult(overrides: Partial<RewindResult> = {}): 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<PerSessionState> = {}) {
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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?' })
})
})
+32 -46
View File
@@ -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<string, { content: unknown; isError: boolean }>()
const result = new Map<string, { content: unknown; isError: boolean }>()
@@ -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)}
/>
<ActionDialog
open={editResendDialog.open}
onClose={editResendDialog.onClose}
title={editResendDialog.title}
body={editResendDialog.body}
actions={editResendDialog.actions}
width={520}
loading={editResendDialog.loading}
/>
</div>
)
}
@@ -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}
@@ -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> = {}): 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<HTMLElement>('[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(<UserMessage content="A prompt" branchAction={{ label: 'Fork', onBranch: vi.fn() }} rewindAction={{ label: 'Roll back conversation', onRewind: rollback }} />)
const button = screen.getByRole('button', { name: 'Roll back conversation' })
const editAction = makeEditAction()
const { container } = render(<UserMessage content="A prompt" branchAction={{ label: 'Fork', onBranch: vi.fn() }} editAction={editAction} />)
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(<UserMessage content="Original prompt" editAction={editAction} />)
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(
<UserMessage
content=""
attachments={[{ type: 'image', name: 'shot.png', data: 'data:image/png;base64,AAAA' }]}
editAction={makeEditAction()}
/>,
)
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(<UserMessage content="Do the review" teammateFrom="reviewer" editAction={makeEditAction()} />)
expect(screen.queryByRole('button', { name: 'Edit and resend' })).toBeNull()
})
it('keeps long URLs inside the message bubble', () => {
+50 -5
View File
@@ -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<MessageEditAction | undefined>(
() => 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 (
<div className="flex justify-end">
<div
data-message-shell="user"
data-editing="true"
className="flex w-full min-w-0 max-w-[82%] flex-col items-stretch sm:max-w-[78%] lg:max-w-[640px]"
>
<UserMessageEditor
initialDraft={editAction.getDraft()}
submitting={editAction.submitting}
disabled={editAction.disabled}
onDraftChange={editAction.onDraftChange}
onCancel={editAction.onCancel}
onSubmit={editAction.onSubmit}
/>
</div>
</div>
)
}
return (
<div className="flex justify-end">
<div
@@ -180,12 +225,12 @@ export const UserMessage = memo(function UserMessage({
)}
</div>
{hasText && (
{(hasText || actionBarEditAction) && (
<MessageActionBar
copyText={content}
copyLabel={t('chat.copyPrompt')}
branchAction={branchAction}
rewindAction={rewindAction}
editAction={actionBarEditAction}
align="end"
timestamp={timestamp}
/>
@@ -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<Parameters<typeof UserMessageEditor>[0]> = {}) {
const props = {
initialDraft: DRAFT,
submitting: false,
disabled: false,
onDraftChange: vi.fn(),
onCancel: vi.fn(),
onSubmit: vi.fn(),
...overrides,
}
render(<UserMessageEditor {...props} />)
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(
<div onKeyDown={listKeyDown}>
<UserMessageEditor
initialDraft={DRAFT}
submitting={false}
disabled={false}
onDraftChange={vi.fn()}
onCancel={vi.fn()}
onSubmit={vi.fn()}
/>
</div>,
)
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()
})
})
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
// 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 (
<div
data-testid="user-message-editor"
className="flex w-full flex-col gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
>
{draft.attachments.length > 0 ? (
<AttachmentGallery
attachments={draft.attachments}
variant="composer"
onRemove={submitting ? undefined : (id) => updateDraft({
...draft,
attachments: draft.attachments.filter((attachment) => attachment.id !== id),
})}
/>
) : null}
{unavailableCount > 0 ? (
<p className="px-0.5 text-xs text-[var(--color-warning)]">
{t('chat.editAttachmentUnavailable', { count: unavailableCount })}
</p>
) : null}
<TextArea
ref={textareaRef}
aria-label={t('chat.editMessageInputLabel')}
value={draft.text}
rows={rows}
disabled={submitting}
onChange={(event) => updateDraft({ ...draft, text: event.target.value })}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
className="chat-reading-text resize-none"
/>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => onSubmit(draft)}
disabled={!canSubmit}
loading={submitting}
>
{t('chat.editMessageSend')}
</Button>
</div>
</div>
)
}
+81
View File
@@ -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, string | number>) => 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<SessionRewindResponse> {
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)
}
@@ -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, string | number>) => 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<UIMessage, { type: 'user_text' }> {
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<string | null>(null)
const [submittingMessageId, setSubmittingMessageId] = useState<string | null>(null)
const [confirm, setConfirm] = useState<PendingConfirm | null>(null)
const draftsRef = useRef(new Map<string, UserMessageEditDraft>())
const messagesRef = useRef(messages)
messagesRef.current = messages
const messageById = useMemo(() => {
const result = new Map<string, Extract<UIMessage, { type: 'user_text' }>>()
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<string, EditableTurnCard>()
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<string, UserMessageEditAction>()
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<UserMessageEditDialog>(() => {
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 = (
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-secondary)]">
{lines.map((line, index) => <p key={index}>{line}</p>)}
{caution ? <p className="text-[var(--color-warning)]">{caution}</p> : null}
</div>
)
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 }
}
@@ -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<session_references>\nReferenced sessions:\n[{"sessionId":"session-b"}]\n</session_references>',
})
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<Extract<UIMessage, { type: 'user_text' }>> = {}): 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)
})
})
@@ -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<UIMessage, { type: 'user_text' }>
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<UserTextMessage, 'content' | 'modelContent' | 'attachments' | 'sessionReferences'>,
): 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
}
@@ -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(<TextArea label="Prompt" rows={10} />)
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<HTMLTextAreaElement>()
render(<TextArea label="Prompt" ref={ref} />)
expect(ref.current).toBe(screen.getByLabelText('Prompt'))
})
})
+8 -4
View File
@@ -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<HTMLTextAreaElement> & {
* 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 `<textarea>` so callers can focus it and place the
* caret, e.g. an inline editor that opens with the cursor at the end.
*/
export function TextArea({
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea({
label,
hint,
error,
@@ -29,7 +32,7 @@ export function TextArea({
id,
disabled,
...props
}: TextAreaProps) {
}, ref) {
const generatedId = useId()
const textareaId = id ?? generatedId
const hintId = `${textareaId}-hint`
@@ -45,6 +48,7 @@ export function TextArea({
</label>
)}
<textarea
ref={ref}
id={textareaId}
rows={rows}
disabled={disabled}
@@ -61,4 +65,4 @@ export function TextArea({
: null}
</div>
)
}
})
+14
View File
@@ -2834,6 +2834,20 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.rewindSuccessPartialCoverage': 'Rewound {count} messages and restored the checkpointed files; changes from {sources} were not checkpointed and remain on disk.',
'chat.conversationRewindAction': 'Roll back conversation',
'chat.conversationRewindConfirmBody': 'This will rewind the conversation to before this turn. Files on disk will not be changed.',
'chat.editMessage': 'Edit and resend',
'chat.editMessageInputLabel': 'Edited message',
'chat.editMessageSend': 'Send',
'chat.editAttachmentUnavailable': '{count} attachment(s) can no longer be read and will be left out when resending.',
'chat.editResendConfirmTitle': 'Edit and resend?',
'chat.editResendDropsTurns': 'The {count} later turn(s) after this message will be deleted, then the edited message is sent.',
'chat.editResendLatestBody': 'The reply to this turn will be deleted, then the edited message is sent.',
'chat.editResendRestoreChoice': '{count} file(s) were changed from this turn on. You can roll back only the conversation and keep the files on disk, or restore those files as well.',
'chat.editResendFilesUntouched': 'Files on disk will not be changed.',
'chat.editResendConversationOnly': 'Roll back conversation only and send',
'chat.editResendWithCode': 'Roll back code and conversation and send',
'chat.editResendConversation': 'Roll back conversation and send',
'chat.editResendFailed': 'Could not resend the edited message. The conversation was not changed. Details: {detail}',
'chat.editResendPrefilled': 'The conversation was rolled back, but the edited message could not be sent automatically. It is back in the input box.',
'chat.workspaceChangesFallbackLabel': 'Workspace changed files',
'chat.workspaceChangesFallbackTitle': 'Current workspace changes ({count})',
'chat.workspaceChangesFallbackExplanation': 'This conversation is too long for turn previews or file undo. This list shows current workspace changes and may include changes from other turns or outside this conversation.',
+14
View File
@@ -2835,6 +2835,20 @@ export const jp: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '{count} 件のメッセージを巻き戻し、記録済みのファイルを復元しました。{sources} による変更は記録されておらず、ディスク上に残っています。',
'chat.conversationRewindAction': '会話を巻き戻す',
'chat.conversationRewindConfirmBody': '会話をこのターンの前まで巻き戻します。ディスク上のファイルは変更されません。',
'chat.editMessage': '編集して再送信',
'chat.editMessageInputLabel': '編集したメッセージ',
'chat.editMessageSend': '送信',
'chat.editAttachmentUnavailable': '{count} 件の添付ファイルを読み込めなくなったため、再送信では省略されます。',
'chat.editResendConfirmTitle': '編集して再送信しますか?',
'chat.editResendDropsTurns': 'このメッセージ以降の {count} ターンの会話が削除され、編集したメッセージが送信されます。',
'chat.editResendLatestBody': 'このターンの返信が削除され、編集したメッセージが送信されます。',
'chat.editResendRestoreChoice': 'このターン以降に {count} 件のファイルが変更されています。会話のみを巻き戻してディスク上のファイルを残すことも、ファイルも一緒に復元することもできます。',
'chat.editResendFilesUntouched': 'ディスク上のファイルは変更されません。',
'chat.editResendConversationOnly': '会話のみ巻き戻して送信',
'chat.editResendWithCode': 'コードと会話を巻き戻して送信',
'chat.editResendConversation': '会話を巻き戻して送信',
'chat.editResendFailed': '編集したメッセージを再送信できませんでした。会話は変更されていません。詳細:{detail}',
'chat.editResendPrefilled': '会話は巻き戻されましたが、編集したメッセージを自動送信できなかったため、入力欄に戻しました。',
'chat.workspaceChangesFallbackLabel': 'ワークスペースの変更ファイル',
'chat.workspaceChangesFallbackTitle': '現在のワークスペースの変更({count})',
'chat.workspaceChangesFallbackExplanation': '会話が長すぎるため、ターンごとのプレビューやファイルの取り消しは利用できません。この一覧は現在のワークスペース全体の変更を示し、他のターンやこの会話外の変更を含む場合があります。',
+14
View File
@@ -2837,6 +2837,20 @@ export const kr: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '메시지 {count}개를 되돌리고 기록된 파일을 복원했습니다. {sources}(으)로 인한 변경은 기록되지 않아 디스크에 그대로 남아 있습니다.',
'chat.conversationRewindAction': '대화 되돌리기',
'chat.conversationRewindConfirmBody': '대화를 이 턴 이전으로 되감습니다. 디스크의 파일은 변경되지 않습니다.',
'chat.editMessage': '편집 후 다시 보내기',
'chat.editMessageInputLabel': '편집한 메시지',
'chat.editMessageSend': '보내기',
'chat.editAttachmentUnavailable': '첨부 파일 {count}개를 더 이상 읽을 수 없어 다시 보낼 때 제외됩니다.',
'chat.editResendConfirmTitle': '편집 후 다시 보낼까요?',
'chat.editResendDropsTurns': '이 메시지 이후의 대화 {count}턴이 삭제된 뒤 편집한 메시지가 전송됩니다.',
'chat.editResendLatestBody': '이 턴의 답변이 삭제된 뒤 편집한 메시지가 전송됩니다.',
'chat.editResendRestoreChoice': '이 턴부터 파일 {count}개가 변경되었습니다. 대화만 되돌리고 디스크의 파일은 유지하거나, 해당 파일도 함께 복원할 수 있습니다.',
'chat.editResendFilesUntouched': '디스크의 파일은 변경되지 않습니다.',
'chat.editResendConversationOnly': '대화만 되돌리고 보내기',
'chat.editResendWithCode': '코드와 대화를 되돌리고 보내기',
'chat.editResendConversation': '대화를 되돌리고 보내기',
'chat.editResendFailed': '편집한 메시지를 다시 보내지 못했습니다. 대화는 변경되지 않았습니다. 세부 정보: {detail}',
'chat.editResendPrefilled': '대화는 되돌렸지만 편집한 메시지를 자동으로 보내지 못해 입력창에 다시 넣었습니다.',
'chat.workspaceChangesFallbackLabel': '워크스페이스 변경 파일',
'chat.workspaceChangesFallbackTitle': '현재 워크스페이스 변경 ({count})',
'chat.workspaceChangesFallbackExplanation': '대화가 너무 길어 턴별 미리보기나 파일 실행 취소를 사용할 수 없습니다. 이 목록은 현재 워크스페이스의 누적 변경을 표시하며 다른 턴이나 이 대화 밖의 변경도 포함할 수 있습니다.',
+14
View File
@@ -2834,6 +2834,20 @@ export const zh: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '已回滾 {count} 則訊息並還原已記錄的檔案;{sources} 造成的變更未被記錄,仍留在磁碟上。',
'chat.conversationRewindAction': '回滾對話',
'chat.conversationRewindConfirmBody': '這會把對話回滾到這一輪之前,磁碟上的檔案不會被變更。',
'chat.editMessage': '編輯並重新傳送',
'chat.editMessageInputLabel': '編輯訊息內容',
'chat.editMessageSend': '傳送',
'chat.editAttachmentUnavailable': '{count} 個附件已無法讀取,重新傳送時會被省略。',
'chat.editResendConfirmTitle': '編輯並重新傳送?',
'chat.editResendDropsTurns': '這則訊息之後的 {count} 輪對話會被刪除,然後傳送修改後的訊息。',
'chat.editResendLatestBody': '這一輪的回覆會被刪除,然後傳送修改後的訊息。',
'chat.editResendRestoreChoice': '從這一輪開始共有 {count} 個檔案被變更。可以只回滾對話、保留磁碟上的檔案,也可以把這些檔案一起還原。',
'chat.editResendFilesUntouched': '磁碟上的檔案不會被變更。',
'chat.editResendConversationOnly': '僅回滾對話並傳送',
'chat.editResendWithCode': '回滾程式碼和對話並傳送',
'chat.editResendConversation': '回滾對話並傳送',
'chat.editResendFailed': '編輯後重新傳送失敗,對話沒有變化。詳情:{detail}',
'chat.editResendPrefilled': '對話已回滾,但修改後的訊息沒能自動傳送,已放回輸入框。',
'chat.workspaceChangesFallbackLabel': '工作區變更檔案',
'chat.workspaceChangesFallbackTitle': '目前工作區變更({count})',
'chat.workspaceChangesFallbackExplanation': '對話過長,無法提供逐輪預覽或檔案復原。此清單顯示目前工作區的累計變更,可能包含其他輪次或對話外的變更。',
+14
View File
@@ -2833,6 +2833,20 @@ export const zh: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '已回滚 {count} 条消息并恢复已记录的文件;{sources} 造成的改动未被记录,仍留在磁盘上。',
'chat.conversationRewindAction': '回滚对话',
'chat.conversationRewindConfirmBody': '这会把对话回滚到这一轮之前,磁盘上的文件不会被更改。',
'chat.editMessage': '编辑并重新发送',
'chat.editMessageInputLabel': '编辑消息内容',
'chat.editMessageSend': '发送',
'chat.editAttachmentUnavailable': '{count} 个附件已无法读取,重新发送时会被省略。',
'chat.editResendConfirmTitle': '编辑并重新发送?',
'chat.editResendDropsTurns': '这条消息之后的 {count} 轮对话会被删除,然后发送修改后的消息。',
'chat.editResendLatestBody': '这一轮的回复会被删除,然后发送修改后的消息。',
'chat.editResendRestoreChoice': '从这一轮开始共有 {count} 个文件被改动。可以只回滚对话、保留磁盘上的文件,也可以把这些文件一起还原。',
'chat.editResendFilesUntouched': '磁盘上的文件不会被更改。',
'chat.editResendConversationOnly': '仅回滚对话并发送',
'chat.editResendWithCode': '回滚代码和对话并发送',
'chat.editResendConversation': '回滚对话并发送',
'chat.editResendFailed': '编辑后重新发送失败,对话没有变化。详情:{detail}',
'chat.editResendPrefilled': '对话已回滚,但修改后的消息没能自动发送,已放回输入框。',
'chat.workspaceChangesFallbackLabel': '工作区更改文件',
'chat.workspaceChangesFallbackTitle': '当前工作区改动({count})',
'chat.workspaceChangesFallbackExplanation': '会话过长,无法提供逐轮预览或文件撤销。此列表显示当前工作区的累计改动,可能包含其他轮次或会话外的改动。',
+2 -2
View File
@@ -7357,7 +7357,7 @@ function parseWorkspaceReferenceHistoryPrompt(text: string): WorkspaceReferenceH
}
}
function pathsReferToSameFile(left: string | undefined, right: string | undefined): boolean {
export function pathsReferToSameFile(left: string | undefined, right: string | undefined): boolean {
if (!left || !right) return false
const normalizedLeft = left.replace(/\\/g, '/').replace(/^\.\//, '')
const normalizedRight = right.replace(/\\/g, '/').replace(/^\.\//, '')
@@ -7375,7 +7375,7 @@ type RestoredUserDisplay = {
modelContent?: string
}
function extractRestoredUserDisplay(text: string): RestoredUserDisplay {
export function extractRestoredUserDisplay(text: string): RestoredUserDisplay {
const referenceContext = splitSessionReferenceContext(text)
if (referenceContext.sessionReferences.length) {
return { ...extractRestoredUserDisplay(referenceContext.content), sessionReferences: referenceContext.sessionReferences, modelContent: text }
+16
View File
@@ -80,6 +80,22 @@ Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件
如果某一轮的文件检查点本身残缺(会话记录损坏、路径不安全等),代码就还原不了了,确认框里只剩「只回滚对话」——对话永远能退回去。
### 编辑历史消息并重新运行
想改一下某条提示词再跑一遍(比如回复被截断、需求没说清),把鼠标移到自己发过的消息上,点铅笔图标「编辑并重新发送」。消息会原地变成编辑框,原来的附件和引用以标签形式保留,可以删掉不要的;按 Enter 发送(跟随设置里的发送方式),Esc 取消。取消时会话不会有任何变化。
能编辑的消息和能回滚的轮次是同一批:已经完成的轮次,包括失败或中断的轮次。助手还在运行、有后台任务、子 Agent 或团队成员会话、侧边对话里不提供编辑。
发送后会把会话回滚到这条消息之前,再发送修改后的内容,回滚方式和上面的撤销完全一样:
- 只是最近一轮、也没有可还原的文件改动时,直接回滚对话并发送,不再确认。
- 编辑更早的消息时,确认框会说明这条消息之后有几轮对话会被删除。
- 从这一轮开始有可还原的文件改动时,可以选「回滚代码和对话并发送」,或「仅回滚对话并发送」(磁盘上的文件保持现状)。文件检查点残缺时只剩后者。
如果回滚没成功,对话和文件都不会变,编辑框保留你写的内容。如果回滚成功但消息没能自动发出,修改后的内容会放回底部输入框,不会丢。
编辑一条发生在上下文压缩之前的消息时,会话会退回到压缩之前,重新运行时用的是完整的原始上下文。
## 活动面板:它现在在忙什么
标签栏右侧第一个按钮打开活动面板,里面按区块列出这条会话的所有并行工作:
+16
View File
@@ -80,6 +80,22 @@ Checkpoints capture the files Claude changed through its editing tools. **Files
When a turn's file checkpoint is itself incomplete (a damaged session log, an unsafe path), the code cannot be restored and the confirmation offers only **Roll back conversation only** — the conversation can always be rewound.
### Editing a message and running it again
To reword a prompt and run it again (a truncated reply, a request that missed a constraint), hover one of your own messages and click the pencil, **Edit and resend**. The message turns into an editor in place. Its attachments and references stay as chips you can remove. Enter sends (following your send-key setting) and Esc cancels. Cancelling leaves the session untouched.
The messages you can edit are the same turns you can roll back: completed turns, including failed or interrupted ones. Editing is not offered while Claude is running, while background tasks run, in subagent or team-member sessions, or in a side chat.
Sending rewinds the session to before that message and then sends the edited text, using exactly the rollback described above:
- For the latest turn with no restorable file changes, the conversation is rolled back and the edit sent without asking.
- For an older message, the confirmation says how many later turns will be deleted.
- When files changed from that turn on and can be restored, choose **Roll back code and conversation and send** or **Roll back conversation only and send** (files on disk stay as they are). With an incomplete file checkpoint, only the second is offered.
If the rollback fails, neither the conversation nor the files change, and the editor keeps your text. If the rollback succeeds but the message cannot be sent automatically, your edited text goes back into the composer instead of being lost.
Editing a message from before a context compaction rewinds past the compaction, so the rerun works from the full original context.
## The Activity panel
The first button on the right of the tab bar opens the Activity panel, which lists everything running in parallel for this session: