mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): explain why a prompt cannot be edited instead of hiding it (#1456)
The edit-and-resend action disappeared whenever an edit could not start: while a turn ran, while any background task (a dev server, a background agent) was still running, while turn checkpoints loaded, or when they failed. With nothing on screen it read as the feature being gone. Completed prompts now keep the action, disabled, with the reason on hover and as its accessible description. An open editor and its draft survive a background task starting. Sessions past the checkpoint preview budget can edit again by resolving each prompt through its transcript id; since that mode already tells the user file undo is unavailable, those edits roll back the conversation only.
This commit is contained in:
@@ -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.
|
||||
<span className="inline-flex" title={editAction.disabledReason}>
|
||||
<IconButton
|
||||
icon={<Pencil size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
|
||||
label={editAction.label}
|
||||
showTooltip={false}
|
||||
size="xs"
|
||||
tone="muted"
|
||||
disabled
|
||||
aria-describedby={editReasonId}
|
||||
/>
|
||||
<span id={editReasonId} className="sr-only">{editAction.disabledReason}</span>
|
||||
</span>
|
||||
) : editAction ? (
|
||||
<IconButton
|
||||
icon={<Pencil size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
|
||||
label={editAction.label}
|
||||
|
||||
@@ -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(<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())
|
||||
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(<MessageList />)
|
||||
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(<MessageList />)
|
||||
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(<MessageList />)
|
||||
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(<MessageList />)
|
||||
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(<MessageList />)
|
||||
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())
|
||||
|
||||
@@ -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<ConversationFindMatch | null>(null)
|
||||
const conversationFindMatchesRef = useRef<ConversationFindMatch[]>([])
|
||||
const [messageListWidth, setMessageListWidth] = useState<number | null>(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<EditableTurnCard[]>(
|
||||
() => 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,
|
||||
|
||||
@@ -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<MessageEditAction | undefined>(
|
||||
() => editAction
|
||||
? { label: editAction.label, disabled: editAction.disabled, onEdit: editAction.onStart }
|
||||
? {
|
||||
label: editAction.label,
|
||||
disabled: editAction.disabled,
|
||||
disabledReason: editAction.disabledReason,
|
||||
onEdit: editAction.onStart,
|
||||
}
|
||||
: undefined,
|
||||
[editAction],
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ type Translate = (key: TranslationKey, params?: Record<string, string | number>)
|
||||
export async function rewindToTurnCheckpoint(
|
||||
sessionId: string,
|
||||
request: {
|
||||
checkpointTarget: SessionTurnCheckpoint['target']
|
||||
checkpointTarget: Pick<SessionTurnCheckpoint['target'], 'targetUserMessageId' | 'userMessageIndex'>
|
||||
expectedContent: string
|
||||
},
|
||||
mode: SessionRewindMode,
|
||||
|
||||
@@ -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<T>() {
|
||||
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<Availability> = {}) {
|
||||
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<Availability>) => 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<typeof setup>['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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,9 @@ type Translate = (key: TranslationKey, params?: Record<string, string | number>)
|
||||
/** 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<SessionTurnCheckpoint['target'], 'targetUserMessageId' | 'userMessageIndex'> }
|
||||
/** 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<string>
|
||||
/** 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<string, UserMessageEditAction>()
|
||||
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<UserMessageEditDialog>(() => {
|
||||
const busy = Boolean(rewindingTurnId)
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -2891,6 +2891,10 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.editMessage': '編集して再送信',
|
||||
'chat.editMessageInputLabel': '編集したメッセージ',
|
||||
'chat.editMessageSend': '送信',
|
||||
'chat.editMessageWaitForTurn': 'このターンが終わると編集できます。',
|
||||
'chat.editMessageWaitForBackgroundTasks': '実行中のバックグラウンドタスクが終了または停止すると編集できます。',
|
||||
'chat.editMessageCheckingCheckpoints': 'このメッセージまで巻き戻せるか確認しています…',
|
||||
'chat.editMessageCheckpointsUnavailable': 'このセッションのチェックポイントを読み込めないため、現在は編集できません。',
|
||||
'chat.editAttachmentUnavailable': '{count} 件の添付ファイルを読み込めなくなったため、再送信では省略されます。',
|
||||
'chat.editResendConfirmTitle': '編集して再送信しますか?',
|
||||
'chat.editResendDropsTurns': 'このメッセージ以降の {count} ターンの会話が削除され、編集したメッセージが送信されます。',
|
||||
|
||||
@@ -2893,6 +2893,10 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.editMessage': '편집 후 다시 보내기',
|
||||
'chat.editMessageInputLabel': '편집한 메시지',
|
||||
'chat.editMessageSend': '보내기',
|
||||
'chat.editMessageWaitForTurn': '이 턴이 끝나면 편집할 수 있습니다.',
|
||||
'chat.editMessageWaitForBackgroundTasks': '실행 중인 백그라운드 작업이 끝나거나 중지되면 편집할 수 있습니다.',
|
||||
'chat.editMessageCheckingCheckpoints': '이 메시지로 되감을 수 있는지 확인하는 중…',
|
||||
'chat.editMessageCheckpointsUnavailable': '이 세션의 체크포인트를 불러올 수 없어 지금은 편집할 수 없습니다.',
|
||||
'chat.editAttachmentUnavailable': '첨부 파일 {count}개를 더 이상 읽을 수 없어 다시 보낼 때 제외됩니다.',
|
||||
'chat.editResendConfirmTitle': '편집 후 다시 보낼까요?',
|
||||
'chat.editResendDropsTurns': '이 메시지 이후의 대화 {count}턴이 삭제된 뒤 편집한 메시지가 전송됩니다.',
|
||||
|
||||
@@ -2890,6 +2890,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.editMessage': '編輯並重新傳送',
|
||||
'chat.editMessageInputLabel': '編輯訊息內容',
|
||||
'chat.editMessageSend': '傳送',
|
||||
'chat.editMessageWaitForTurn': '這一輪結束後才能編輯。',
|
||||
'chat.editMessageWaitForBackgroundTasks': '後臺任務仍在執行,結束或停止後才能編輯。',
|
||||
'chat.editMessageCheckingCheckpoints': '正在確認這則訊息能否回滾…',
|
||||
'chat.editMessageCheckpointsUnavailable': '無法讀取此對話的檢查點,暫時不能編輯。',
|
||||
'chat.editAttachmentUnavailable': '{count} 個附件已無法讀取,重新傳送時會被省略。',
|
||||
'chat.editResendConfirmTitle': '編輯並重新傳送?',
|
||||
'chat.editResendDropsTurns': '這則訊息之後的 {count} 輪對話會被刪除,然後傳送修改後的訊息。',
|
||||
|
||||
@@ -2889,6 +2889,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.editMessage': '编辑并重新发送',
|
||||
'chat.editMessageInputLabel': '编辑消息内容',
|
||||
'chat.editMessageSend': '发送',
|
||||
'chat.editMessageWaitForTurn': '这一轮结束后才能编辑。',
|
||||
'chat.editMessageWaitForBackgroundTasks': '后台任务仍在运行,结束或停止后才能编辑。',
|
||||
'chat.editMessageCheckingCheckpoints': '正在确认这条消息能否回滚…',
|
||||
'chat.editMessageCheckpointsUnavailable': '无法读取本会话的检查点,暂时不能编辑。',
|
||||
'chat.editAttachmentUnavailable': '{count} 个附件已无法读取,重新发送时会被省略。',
|
||||
'chat.editResendConfirmTitle': '编辑并重新发送?',
|
||||
'chat.editResendDropsTurns': '这条消息之后的 {count} 轮对话会被删除,然后发送修改后的消息。',
|
||||
|
||||
Reference in New Issue
Block a user