diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx
index a27c077e..3bbdc655 100644
--- a/desktop/src/components/chat/MessageActionBar.tsx
+++ b/desktop/src/components/chat/MessageActionBar.tsx
@@ -1,5 +1,5 @@
import { Check, Copy, GitFork, Pencil } from 'lucide-react'
-import type { ReactNode } from 'react'
+import { useId, type ReactNode } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
import { CopyButton } from '@/components/ui/CopyButton'
@@ -14,6 +14,8 @@ export type MessageBranchAction = {
export type MessageEditAction = {
label: string
disabled?: boolean
+ /** Shown on hover while disabled, so the action says why rather than vanishing. */
+ disabledReason?: string
onEdit: () => void
}
@@ -68,6 +70,7 @@ export function MessageActionBar({
placement = 'inline',
}: Props) {
const locale = useSettingsStore((state) => state.locale)
+ const editReasonId = useId()
const hasCopy = Boolean(copyText?.trim())
const hoverTimeLabel = typeof timestamp === 'number'
? formatMessageHoverTime(timestamp, locale)
@@ -116,7 +119,22 @@ export function MessageActionBar({
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
- {editAction ? (
+ {editAction?.disabled && editAction.disabledReason ? (
+ // A disabled button takes no pointer events, so its own title would
+ // never show. The reason hangs on a wrapper that still hovers.
+
+ }
+ label={editAction.label}
+ showTooltip={false}
+ size="xs"
+ tone="muted"
+ disabled
+ aria-describedby={editReasonId}
+ />
+ {editAction.disabledReason}
+
+ ) : editAction ? (
}
label={editAction.label}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index d4e3d065..c0e27801 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -9810,14 +9810,27 @@ describe('MessageList edit and resend', () => {
{ id: 'assistant-3', type: 'assistant_text', content: 'Third answer', timestamp: 6 },
]
+ async function enabledEditButton(bubble: HTMLElement) {
+ // The bubble renders before the turn checkpoints load; until they do the
+ // action is shown disabled, and only then is the prompt known rewindable.
+ return waitFor(() => {
+ const button = within(bubble).getByRole('button', { name: 'Edit and resend' }) as HTMLButtonElement
+ expect(button.disabled).toBe(false)
+ return button
+ })
+ }
+
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' }))
+ fireEvent.click(await enabledEditButton(bubble))
return screen.getByRole('textbox', { name: 'Edited message' }) as HTMLTextAreaElement
}
+ function editButtonIn(content: string) {
+ const bubble = screen.getByText(content).closest('[data-message-shell="user"]') as HTMLElement
+ return within(bubble).getByRole('button', { name: 'Edit and resend' }) as HTMLButtonElement
+ }
+
function typeAndSend(textbox: HTMLTextAreaElement, text: string) {
fireEvent.change(textbox, { target: { value: text } })
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
@@ -9852,23 +9865,26 @@ describe('MessageList edit and resend', () => {
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())
+ await enabledEditButton(first)
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 () => {
+ it('keeps edit visible but disabled, with the reason, while a turn runs; none in a side chat', async () => {
setup(oneTurn, [checkpoint('user-1', 0, 1)])
const { unmount } = render()
- await screen.findByRole('button', { name: 'Edit and resend' })
+ await enabledEditButton((await screen.findByText('Build a page')).closest('[data-message-shell="user"]') as HTMLElement)
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()
+ const busy = editButtonIn('Build a page')
+ expect(busy.disabled).toBe(true)
+ expect(screen.getByRole('button', { name: 'Edit and resend', description: 'Editing is available once this turn finishes.' })).toBe(busy)
+ expect(busy.closest('[title]')?.getAttribute('title')).toBe('Editing is available once this turn finishes.')
unmount()
setup(oneTurn, [checkpoint('user-1', 0, 1)])
@@ -9880,6 +9896,83 @@ describe('MessageList edit and resend', () => {
expect(screen.queryByRole('button', { name: 'Edit and resend' })).toBeNull()
})
+ it('keeps edit disabled while a background task runs, even when the checkpoints never loaded, then enables it', async () => {
+ const runningTask = {
+ taskId: 'shell-1', status: 'running', taskType: 'local_bash', description: 'npm run dev', startedAt: 1, updatedAt: 2,
+ } as const
+ setup(oneTurn, [checkpoint('user-1', 0, 1)], { backgroundAgentTasks: { 'shell-1': runningTask } })
+ render()
+ await screen.findByText('Build a page')
+
+ const blocked = editButtonIn('Build a page')
+ expect(blocked.disabled).toBe(true)
+ expect(screen.getByRole('button', {
+ name: 'Edit and resend',
+ description: 'Editing is available once the running background tasks finish or are stopped.',
+ })).toBe(blocked)
+ expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
+
+ act(() => {
+ const current = useChatStore.getState().sessions[ACTIVE_TAB]!
+ useChatStore.setState({ sessions: { [ACTIVE_TAB]: {
+ ...current, backgroundAgentTasks: { 'shell-1': { ...runningTask, status: 'completed', updatedAt: 3 } },
+ } } })
+ })
+ const bubble = screen.getByText('Build a page').closest('[data-message-shell="user"]') as HTMLElement
+ await enabledEditButton(bubble)
+ })
+
+ it('says the prompt is being checked while the checkpoints load, and why when they fail', async () => {
+ let rejectCheckpoints!: (error: Error) => void
+ setup(oneTurn, [])
+ vi.mocked(sessionsApi.getTurnCheckpoints).mockReturnValue(new Promise((_, reject) => { rejectCheckpoints = reject }))
+ render()
+ await screen.findByText('Build a page')
+ await waitFor(() => expect(editButtonIn('Build a page').disabled).toBe(true))
+ expect(screen.getByRole('button', { name: 'Edit and resend', description: 'Checking whether this prompt can be rewound…' })).toBeTruthy()
+
+ await act(async () => { rejectCheckpoints(new Error('checkpoint store offline')) })
+ await waitFor(() => expect(screen.getByRole('button', {
+ name: 'Edit and resend',
+ description: "Editing is unavailable: this session's checkpoints could not be loaded.",
+ })).toBeTruthy())
+ expect(editButtonIn('Build a page').disabled).toBe(true)
+ })
+
+ it('still edits a prompt past the checkpoint preview budget, rolling back the conversation only', async () => {
+ const { sendMessage } = setup(oneTurn, [])
+ vi.mocked(sessionsApi.getTurnCheckpoints).mockRejectedValue(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
+ // The dry run finds changed files; this mode has told the user file undo
+ // is unavailable, so they must not be offered back.
+ const rewind = vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(rewindResult({
+ code: { available: true, filesChanged: ['app.ts'], insertions: 1, deletions: 0 },
+ }))
+ render()
+ await screen.findByRole('region', { name: 'Workspace changed files' })
+
+ typeAndSend(await openEditorFor('Build a page'), 'Build a landing page')
+
+ await waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
+ expect(screen.queryByRole('dialog')).toBeNull()
+ 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',
+ })
+ })
+
+ it('offers no edit past the budget for a live prompt that has no transcript id yet', async () => {
+ setup([
+ { id: 'live-user', type: 'user_text', content: 'Live prompt', timestamp: 1 },
+ { id: 'live-reply', type: 'assistant_text', content: 'Live answer', timestamp: 2 },
+ ], [])
+ vi.mocked(sessionsApi.getTurnCheckpoints).mockRejectedValue(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
+ render()
+ await screen.findByRole('region', { name: 'Workspace changed files' })
+ 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())
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 60d74d51..3c53d38c 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -33,7 +33,7 @@ import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import { describeRewindResult, getApiErrorMessage, rewindToTurnCheckpoint } from './turnRewind'
-import { useUserMessageEditResend } from './useUserMessageEditResend'
+import { useUserMessageEditResend, type EditableTurnCard } from './useUserMessageEditResend'
import type { UserMessageEditAction } from './UserMessage'
import { WorkspaceChangesFallback } from '@/components/chat/WorkspaceChangesFallback'
import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary'
@@ -119,6 +119,8 @@ type RenderModel = {
type RewindTurnTarget = {
messageId: string
+ /** The prompt's own transcript id; a live prompt has none until history reloads. */
+ transcriptMessageId?: string
userMessageIndex: number
content: string
expectedContent: string
@@ -1244,6 +1246,7 @@ export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget
userMessageIndex += 1
currentTarget = {
messageId: message.id,
+ ...(message.transcriptMessageId ? { transcriptMessageId: message.transcriptMessageId } : {}),
userMessageIndex,
content: message.content,
expectedContent: message.modelContent ?? message.content,
@@ -2434,15 +2437,14 @@ export function MessageList({
const [activeConversationFindMatch, setActiveConversationFindMatch] = useState(null)
const conversationFindMatchesRef = useRef([])
const [messageListWidth, setMessageListWidth] = useState(null)
- const branchActionsDisabled =
- isDirectAgentSession ||
+ const turnInProgress =
isPreparingTurn ||
chatState !== 'idle' ||
- hasRunningBackgroundTasks ||
streamingText.trim().length > 0 ||
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
Boolean(sessionState?.activeToolName)
+ const branchActionsDisabled = isDirectAgentSession || turnInProgress || hasRunningBackgroundTasks
const hasCompactingDivider = messages.some((message) =>
message.type === 'compact_summary' && message.phase === 'compacting')
@@ -3320,11 +3322,49 @@ export function MessageList({
return result
}, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t])
+ const completedTurnMessageIds = useMemo(
+ () => new Set(completedTurnTargets.map((target) => target.messageId)),
+ [completedTurnTargets],
+ )
+ const checkpointPreviewOverBudget = workspaceChangesFallback !== null &&
+ workspaceChangesFallback.sessionId === resolvedSessionId
+ // Past the preview budget the server lists no checkpoints at all, but one
+ // rewind still resolves a prompt by its transcript id, so those prompts stay
+ // editable. This mode tells the user file undo is unavailable, so the edit
+ // rolls back the conversation only.
+ const editTurnCards = useMemo(
+ () => checkpointPreviewOverBudget
+ ? completedTurnTargets.flatMap((target) => target.transcriptMessageId
+ ? [{
+ target: { messageId: target.messageId, expectedContent: target.expectedContent },
+ checkpoint: {
+ target: { targetUserMessageId: target.transcriptMessageId, userMessageIndex: target.userMessageIndex },
+ },
+ conversationOnly: true,
+ }]
+ : [])
+ : visibleTurnChangeCards,
+ [checkpointPreviewOverBudget, completedTurnTargets, visibleTurnChangeCards],
+ )
+ const editBlockedReason = turnInProgress
+ ? t('chat.editMessageWaitForTurn')
+ : hasRunningBackgroundTasks
+ ? t('chat.editMessageWaitForBackgroundTasks')
+ : null
+ const editPendingReason = !checkpointHistoryReady || isLoadingTurnChangeCards
+ ? t('chat.editMessageCheckingCheckpoints')
+ : turnChangeLoadError !== null
+ ? t('chat.editMessageCheckpointsUnavailable')
+ : null
+
const { editActionByMessageId, dialog: editResendDialog } = useUserMessageEditResend({
sessionId: resolvedSessionId,
messages,
- turnCards: visibleTurnChangeCards,
- disabled: branchActionsDisabled || isSideChatSession || Boolean(turnUndoConfirmTargetId),
+ turnCards: editTurnCards,
+ completedMessageIds: completedTurnMessageIds,
+ blockedReason: editBlockedReason,
+ pendingReason: editPendingReason,
+ disabled: isDirectAgentSession || isSideChatSession || Boolean(turnUndoConfirmTargetId),
rewindingTurnId,
setRewindingTurnId,
t,
diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx
index 84ad14ad..5a4b44e8 100644
--- a/desktop/src/components/chat/UserMessage.tsx
+++ b/desktop/src/components/chat/UserMessage.tsx
@@ -22,6 +22,8 @@ export type UserMessageEditAction = {
editing: boolean
submitting: boolean
disabled: boolean
+ /** Why the action is disabled, when the user can do something about it. */
+ disabledReason?: string
getDraft: () => UserMessageEditDraft
onStart: () => void
onCancel: () => void
@@ -64,7 +66,12 @@ export const UserMessage = memo(function UserMessage({
const hasText = content.trim().length > 0
const actionBarEditAction = useMemo(
() => editAction
- ? { label: editAction.label, disabled: editAction.disabled, onEdit: editAction.onStart }
+ ? {
+ label: editAction.label,
+ disabled: editAction.disabled,
+ disabledReason: editAction.disabledReason,
+ onEdit: editAction.onStart,
+ }
: undefined,
[editAction],
)
diff --git a/desktop/src/components/chat/turnRewind.ts b/desktop/src/components/chat/turnRewind.ts
index 0f01a2a9..d17ac215 100644
--- a/desktop/src/components/chat/turnRewind.ts
+++ b/desktop/src/components/chat/turnRewind.ts
@@ -22,7 +22,7 @@ type Translate = (key: TranslationKey, params?: Record)
export async function rewindToTurnCheckpoint(
sessionId: string,
request: {
- checkpointTarget: SessionTurnCheckpoint['target']
+ checkpointTarget: Pick
expectedContent: string
},
mode: SessionRewindMode,
diff --git a/desktop/src/components/chat/useUserMessageEditResend.test.tsx b/desktop/src/components/chat/useUserMessageEditResend.test.tsx
index 2c2e1120..07be9752 100644
--- a/desktop/src/components/chat/useUserMessageEditResend.test.tsx
+++ b/desktop/src/components/chat/useUserMessageEditResend.test.tsx
@@ -5,7 +5,7 @@ import { useChatStore, type PerSessionState } from '@/stores/chatStore'
import { useUIStore } from '@/stores/uiStore'
import type { UIMessage } from '@/types/chat'
import type { TranslationKey } from '@/i18n/locales/en'
-import { useUserMessageEditResend } from './useUserMessageEditResend'
+import { useUserMessageEditResend, type EditableTurnCard } from './useUserMessageEditResend'
import type { UserMessageEditDraft } from './userMessageEdit'
const messages: UIMessage[] = [
@@ -38,13 +38,26 @@ function deferred() {
return { promise, resolve, reject }
}
-function setup() {
+const card: EditableTurnCard = { target: { messageId: 'shared-user', expectedContent: 'continue' }, checkpoint: { target } }
+const completed = new Set(['shared-user'])
+
+type Availability = {
+ turnCards: EditableTurnCard[]
+ blockedReason: string | null
+ pendingReason: string | null
+}
+
+function setup(availability: Partial = {}) {
const setRewindingTurnId = vi.fn()
- return renderHook(({ sessionId }) => useUserMessageEditResend({
- sessionId, messages,
- turnCards: [{ target: { messageId: 'shared-user', expectedContent: 'continue' }, checkpoint: { target } }],
+ return renderHook((props: { sessionId: string } & Partial) => useUserMessageEditResend({
+ sessionId: props.sessionId,
+ messages,
+ turnCards: props.turnCards ?? [card],
+ completedMessageIds: completed,
+ blockedReason: props.blockedReason ?? null,
+ pendingReason: props.pendingReason ?? null,
disabled: false, rewindingTurnId: null, setRewindingTurnId, t: translate,
- }), { initialProps: { sessionId: 'session-a' } })
+ }), { initialProps: { sessionId: 'session-a', ...availability } })
}
describe('useUserMessageEditResend session and recovery boundaries', () => {
@@ -177,4 +190,64 @@ describe('useUserMessageEditResend session and recovery boundaries', () => {
expect(useUIStore.getState().toasts).toEqual([])
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
})
+
+ // The action used to vanish whenever an edit could not start, which read as
+ // the feature being gone. It now stays, disabled, and says why.
+ describe('availability', () => {
+ type Result = ReturnType['result']
+ const actionOf = (result: Result) => result.current.editActionByMessageId.get('shared-user')
+
+ it('keeps a blocked prompt visible with the reason, refuses its submit, and re-enables it once unblocked', async () => {
+ const { result, rerender } = setup({ blockedReason: 'Wait for background tasks' })
+ expect(actionOf(result)).toMatchObject({ disabled: true, disabledReason: 'Wait for background tasks' })
+ await act(async () => actionOf(result)!.onSubmit(draft))
+ expect(sessionsApi.rewind).not.toHaveBeenCalled()
+
+ rerender({ sessionId: 'session-a' })
+ expect(actionOf(result)).toMatchObject({ disabled: false })
+ expect(actionOf(result)!.disabledReason).toBeUndefined()
+ })
+
+ it('reports the block before a missing rewind target', () => {
+ const { result } = setup({ turnCards: [], blockedReason: 'Wait for this turn', pendingReason: 'Checking' })
+ expect(actionOf(result)).toMatchObject({ disabled: true, disabledReason: 'Wait for this turn' })
+ })
+
+ it('says a prompt is being checked until its own target arrives', () => {
+ const { result, rerender } = setup({ turnCards: [], pendingReason: 'Checking' })
+ expect(actionOf(result)).toMatchObject({ disabled: true, disabledReason: 'Checking' })
+ // Other prompts may still be loading; this one already has its target.
+ rerender({ sessionId: 'session-a', turnCards: [card], pendingReason: 'Checking' })
+ expect(actionOf(result)).toMatchObject({ disabled: false })
+ expect(actionOf(result)!.disabledReason).toBeUndefined()
+ })
+
+ it('offers nothing once the settled targets leave the prompt out', () => {
+ const { result } = setup({ turnCards: [] })
+ expect(actionOf(result)).toBeUndefined()
+ })
+
+ it('keeps an open editor and its draft while background work blocks the edit', () => {
+ const { result, rerender } = setup()
+ act(() => {
+ actionOf(result)!.onStart()
+ actionOf(result)!.onDraftChange(draft)
+ })
+ // Running background tasks also withhold the turn cards.
+ rerender({ sessionId: 'session-a', turnCards: [], blockedReason: 'Wait for background tasks' })
+ expect(actionOf(result)).toMatchObject({ editing: true, disabled: true, disabledReason: 'Wait for background tasks' })
+ expect(actionOf(result)!.getDraft().text).toBe(draft.text)
+ })
+
+ it('never offers the files back for a conversation-only target, whatever the dry run finds', async () => {
+ vi.mocked(sessionsApi.rewind).mockResolvedValue(codePreview)
+ vi.spyOn(useChatStore.getState(), 'reloadHistory').mockResolvedValue(undefined)
+ const { result } = setup({ turnCards: [{ ...card, conversationOnly: true }] })
+ await act(async () => actionOf(result)!.onSubmit(draft))
+ await waitFor(() => expect(sessionsApi.rewind).toHaveBeenCalledTimes(2))
+ expect(sessionsApi.rewind).toHaveBeenLastCalledWith('session-a', expect.objectContaining({ mode: 'conversation' }))
+ expect(result.current.dialog.open).toBe(false)
+ await waitFor(() => expect(useChatStore.getState().sendMessage).toHaveBeenCalledWith('session-a', draft.text, [], expect.any(Object)))
+ })
+ })
})
diff --git a/desktop/src/components/chat/useUserMessageEditResend.tsx b/desktop/src/components/chat/useUserMessageEditResend.tsx
index b2d370fa..3529ec0c 100644
--- a/desktop/src/components/chat/useUserMessageEditResend.tsx
+++ b/desktop/src/components/chat/useUserMessageEditResend.tsx
@@ -20,7 +20,9 @@ type Translate = (key: TranslationKey, params?: Record)
/** 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'] }
+ checkpoint: { target: Pick }
+ /** Files are never offered back, whatever the dry run finds: only the conversation rewinds. */
+ conversationOnly?: boolean
}
type EditContext = { sessionId: string | null | undefined; active: boolean }
@@ -40,7 +42,17 @@ type Options = {
sessionId: string | null | undefined
messages: UIMessage[]
turnCards: EditableTurnCard[]
- /** Busy, background work, an agent tab, a side chat: no new edits start. */
+ /**
+ * Prompts whose turn has completed. While an edit cannot start, these keep a
+ * disabled action that says why instead of losing it: a pencil that silently
+ * comes and goes with background work reads as a missing feature.
+ */
+ completedMessageIds: ReadonlySet
+ /** Why no edit can start in this session right now (a turn or background work is running). */
+ blockedReason: string | null
+ /** Why a completed prompt has no rewind target yet; null once the targets are settled. */
+ pendingReason: string | null
+ /** An agent tab, a side chat, an open undo dialog: no edit action at all. */
disabled: boolean
rewindingTurnId: string | null
setRewindingTurnId: (messageId: string | null) => void
@@ -81,6 +93,9 @@ export function useUserMessageEditResend({
sessionId,
messages,
turnCards,
+ completedMessageIds,
+ blockedReason,
+ pendingReason,
disabled,
rewindingTurnId,
setRewindingTurnId,
@@ -202,7 +217,7 @@ export function useUserMessageEditResend({
const requestContext = context
if (!isCurrentContext(requestContext)) return
const card = editableCards.get(messageId)
- if (!sessionId || !card || disabled || submittingMessageId || rewindingTurnId) return
+ if (!sessionId || !card || disabled || blockedReason || submittingMessageId || rewindingTurnId) return
const payload = buildUserMessageResendPayload(draft, { contextReferencesOnly })
if (!payload) return
draftsRef.current.set(JSON.stringify([sessionId, messageId]), draft)
@@ -228,7 +243,8 @@ export function useUserMessageEditResend({
if (!isCurrentContext(requestContext)) return
setSubmitting(null)
- const canRestoreCode = preview.code.available &&
+ const canRestoreCode = !card.conversationOnly &&
+ preview.code.available &&
preview.code.filesChanged.length > 0 &&
preview.restoreAvailable !== false
const laterTurns = countLaterUserTurns(messagesRef.current, messageId)
@@ -239,23 +255,30 @@ export function useUserMessageEditResend({
return
}
setConfirm({ context: requestContext, messageId, card, draft, payload, preview, laterTurns, canRestoreCode })
- }, [context, contextReferencesOnly, disabled, editableCards, isCurrentContext, rewindingTurnId, runResend, sessionId, submittingMessageId, t])
+ }, [blockedReason, context, contextReferencesOnly, disabled, editableCards, isCurrentContext, 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) {
+ for (const messageId of new Set([...editableCards.keys(), ...completedMessageIds])) {
+ const message = messageById.get(messageId)
+ if (!isEditableUserMessage(message)) continue
+ const card = editableCards.get(messageId)
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)!
+ const unavailableReason = blockedReason ?? (card ? null : pendingReason)
+ // Settled targets without this prompt: the rewind API cannot address it.
+ if (!card && !unavailableReason && !editing) continue
result.set(messageId, {
label,
editing,
submitting: submittingMessageId === messageId || rewindingTurnId === messageId,
- disabled: disabled || Boolean(rewindingTurnId && rewindingTurnId !== messageId),
+ disabled: disabled || !card || Boolean(unavailableReason) ||
+ Boolean(rewindingTurnId && rewindingTurnId !== messageId),
+ ...(unavailableReason ? { disabledReason: unavailableReason } : {}),
getDraft: () => draftsRef.current.get(JSON.stringify([sessionId, messageId])) ?? createUserMessageEditDraft(message),
onStart: () => {
setEditing((current) => {
@@ -271,7 +294,7 @@ export function useUserMessageEditResend({
})
}
return result
- }, [closeEditor, disabled, editableCards, editingMessageId, messageById, rewindingTurnId, sessionId, submit, submittingMessageId, t])
+ }, [blockedReason, closeEditor, completedMessageIds, disabled, editableCards, editingMessageId, messageById, pendingReason, rewindingTurnId, sessionId, submit, submittingMessageId, t])
const dialog = useMemo(() => {
const busy = Boolean(rewindingTurnId)
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index cce5ee46..91d0acdf 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -2890,6 +2890,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.editMessage': 'Edit and resend',
'chat.editMessageInputLabel': 'Edited message',
'chat.editMessageSend': 'Send',
+ 'chat.editMessageWaitForTurn': 'Editing is available once this turn finishes.',
+ 'chat.editMessageWaitForBackgroundTasks': 'Editing is available once the running background tasks finish or are stopped.',
+ 'chat.editMessageCheckingCheckpoints': 'Checking whether this prompt can be rewound…',
+ 'chat.editMessageCheckpointsUnavailable': "Editing is unavailable: this session's checkpoints could not be loaded.",
'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.',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index e444ec7e..d8920f9a 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -2891,6 +2891,10 @@ export const jp: Record = {
'chat.editMessage': '編集して再送信',
'chat.editMessageInputLabel': '編集したメッセージ',
'chat.editMessageSend': '送信',
+ 'chat.editMessageWaitForTurn': 'このターンが終わると編集できます。',
+ 'chat.editMessageWaitForBackgroundTasks': '実行中のバックグラウンドタスクが終了または停止すると編集できます。',
+ 'chat.editMessageCheckingCheckpoints': 'このメッセージまで巻き戻せるか確認しています…',
+ 'chat.editMessageCheckpointsUnavailable': 'このセッションのチェックポイントを読み込めないため、現在は編集できません。',
'chat.editAttachmentUnavailable': '{count} 件の添付ファイルを読み込めなくなったため、再送信では省略されます。',
'chat.editResendConfirmTitle': '編集して再送信しますか?',
'chat.editResendDropsTurns': 'このメッセージ以降の {count} ターンの会話が削除され、編集したメッセージが送信されます。',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 6af10a63..d6e9e8de 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -2893,6 +2893,10 @@ export const kr: Record = {
'chat.editMessage': '편집 후 다시 보내기',
'chat.editMessageInputLabel': '편집한 메시지',
'chat.editMessageSend': '보내기',
+ 'chat.editMessageWaitForTurn': '이 턴이 끝나면 편집할 수 있습니다.',
+ 'chat.editMessageWaitForBackgroundTasks': '실행 중인 백그라운드 작업이 끝나거나 중지되면 편집할 수 있습니다.',
+ 'chat.editMessageCheckingCheckpoints': '이 메시지로 되감을 수 있는지 확인하는 중…',
+ 'chat.editMessageCheckpointsUnavailable': '이 세션의 체크포인트를 불러올 수 없어 지금은 편집할 수 없습니다.',
'chat.editAttachmentUnavailable': '첨부 파일 {count}개를 더 이상 읽을 수 없어 다시 보낼 때 제외됩니다.',
'chat.editResendConfirmTitle': '편집 후 다시 보낼까요?',
'chat.editResendDropsTurns': '이 메시지 이후의 대화 {count}턴이 삭제된 뒤 편집한 메시지가 전송됩니다.',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 7693a807..7b54112e 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -2890,6 +2890,10 @@ export const zh: Record = {
'chat.editMessage': '編輯並重新傳送',
'chat.editMessageInputLabel': '編輯訊息內容',
'chat.editMessageSend': '傳送',
+ 'chat.editMessageWaitForTurn': '這一輪結束後才能編輯。',
+ 'chat.editMessageWaitForBackgroundTasks': '後臺任務仍在執行,結束或停止後才能編輯。',
+ 'chat.editMessageCheckingCheckpoints': '正在確認這則訊息能否回滾…',
+ 'chat.editMessageCheckpointsUnavailable': '無法讀取此對話的檢查點,暫時不能編輯。',
'chat.editAttachmentUnavailable': '{count} 個附件已無法讀取,重新傳送時會被省略。',
'chat.editResendConfirmTitle': '編輯並重新傳送?',
'chat.editResendDropsTurns': '這則訊息之後的 {count} 輪對話會被刪除,然後傳送修改後的訊息。',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 6273d584..9591e49e 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -2889,6 +2889,10 @@ export const zh: Record = {
'chat.editMessage': '编辑并重新发送',
'chat.editMessageInputLabel': '编辑消息内容',
'chat.editMessageSend': '发送',
+ 'chat.editMessageWaitForTurn': '这一轮结束后才能编辑。',
+ 'chat.editMessageWaitForBackgroundTasks': '后台任务仍在运行,结束或停止后才能编辑。',
+ 'chat.editMessageCheckingCheckpoints': '正在确认这条消息能否回滚…',
+ 'chat.editMessageCheckpointsUnavailable': '无法读取本会话的检查点,暂时不能编辑。',
'chat.editAttachmentUnavailable': '{count} 个附件已无法读取,重新发送时会被省略。',
'chat.editResendConfirmTitle': '编辑并重新发送?',
'chat.editResendDropsTurns': '这条消息之后的 {count} 轮对话会被删除,然后发送修改后的消息。',