mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
* 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. * fix(desktop): preserve edit-resend session and context
This commit is contained in:
@@ -652,6 +652,46 @@ describe('ChatInput file mentions', () => {
|
||||
expect(getComposerText()).toBe('draft before switching project')
|
||||
})
|
||||
|
||||
it('restores path-only files and images from an edit fallback without losing their paths', async () => {
|
||||
render(<ChatInput compact />)
|
||||
act(() => useChatStore.getState().queueComposerPrefill(sessionId, {
|
||||
text: 'edited prompt',
|
||||
attachments: [
|
||||
{ type: 'file', name: 'notes.md', path: '/repo/notes.md' },
|
||||
{ type: 'image', name: 'shot.png', path: '/repo/shot.png', mimeType: 'image/png' },
|
||||
],
|
||||
}))
|
||||
await waitFor(() => expect(getComposerText()).toBe('edited prompt'))
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, expect.objectContaining({
|
||||
content: 'edited prompt',
|
||||
attachments: [
|
||||
expect.objectContaining({ type: 'file', path: '/repo/notes.md' }),
|
||||
expect.objectContaining({ type: 'image', path: '/repo/shot.png', mimeType: 'image/png' }),
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('restores removable workspace and session references from an edit fallback', async () => {
|
||||
render(<ChatInput compact />)
|
||||
act(() => useChatStore.getState().queueComposerPrefill(sessionId, {
|
||||
text: 'edited prompt',
|
||||
attachments: [{ type: 'file', name: 'app.ts', path: '/repo/app.ts', lineStart: 3, lineEnd: 5, quote: 'for (;;) {}', note: 'stop this loop', diffSide: 'new', hunkId: 'hunk-1' }],
|
||||
sessionReferences: [{ sessionId: 'referenced-session' }],
|
||||
}))
|
||||
await waitFor(() => expect(getComposerText()).toContain('edited prompt'))
|
||||
expect(screen.getByRole('button', { name: /Remove app.ts/ })).toBeTruthy()
|
||||
expect(getComposerElement().querySelector('[data-mention-kind="session"]')).toBeTruthy()
|
||||
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, expect.objectContaining({
|
||||
content: expect.stringContaining('for (;;) {}'),
|
||||
attachments: [expect.objectContaining({ path: '/repo/app.ts', lineStart: 3, lineEnd: 5, quote: 'for (;;) {}', note: 'stop this loop' })],
|
||||
sessionReferences: [{ sessionId: 'referenced-session' }],
|
||||
}))
|
||||
const message = useChatStore.getState().sessions[sessionId]!.messages.findLast((row) => row.type === 'user_text')
|
||||
expect(message).toMatchObject({ attachments: [expect.objectContaining({ diffSide: 'new', hunkId: 'hunk-1' })] })
|
||||
})
|
||||
|
||||
it('restores an unsent composer draft after the composer unmounts', async () => {
|
||||
const { unmount } = render(<ChatInput compact />)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSideChatStore } from '@/stores/sideChatStore'
|
||||
import { openSideChat } from '@/lib/workspace/openSideChat'
|
||||
import { parseSideQuestionCommand } from './composerUtils'
|
||||
import { getSessionReferences } from '@/lib/composerMentions'
|
||||
import { normalizeSessionReferences } from '@/lib/sessionReferences'
|
||||
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
@@ -448,21 +449,49 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
|
||||
if (!composerPrefill || !activeTabId) return
|
||||
|
||||
const nextAttachments = (composerPrefill.attachments ?? [])
|
||||
.filter((attachment) => attachment.type === 'image' || attachment.data)
|
||||
.filter((attachment) => attachment.path || attachment.data)
|
||||
.map((attachment, index) => ({
|
||||
id: `composer-prefill-${composerPrefill.nonce}-${index}`,
|
||||
name: attachment.name,
|
||||
type: attachment.type,
|
||||
mimeType: attachment.mimeType,
|
||||
path: attachment.path,
|
||||
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
|
||||
data: attachment.data,
|
||||
isDirectory: attachment.isDirectory,
|
||||
lineStart: attachment.lineStart,
|
||||
lineEnd: attachment.lineEnd,
|
||||
diffSide: attachment.diffSide,
|
||||
hunkId: attachment.hunkId,
|
||||
note: attachment.note,
|
||||
quote: attachment.quote,
|
||||
}))
|
||||
const contextAttachments = nextAttachments.filter((attachment) => attachment.type === 'file' && attachment.path && (
|
||||
attachment.lineStart || attachment.note?.trim() || attachment.quote?.trim() || attachment.diffSide || attachment.hunkId
|
||||
))
|
||||
const uploads = nextAttachments.filter((attachment) => !contextAttachments.includes(attachment))
|
||||
|
||||
if (composerPrefill.mode === 'append') {
|
||||
setComposerAttachments((previous) => [...previous, ...nextAttachments])
|
||||
setComposerAttachments((previous) => [...previous, ...uploads])
|
||||
} else {
|
||||
setComposerInput(composerPrefill.text, [])
|
||||
setComposerAttachments(nextAttachments)
|
||||
let restored = { text: composerPrefill.text, mentions: [] as ComposerMention[] }
|
||||
for (const reference of normalizeSessionReferences(composerPrefill.sessionReferences)) {
|
||||
const title = useSessionStore.getState().sessions.find((session) => session.id === reference.sessionId)?.title
|
||||
restored = insertMentionIntoText(restored.text, restored.mentions, restored.text.length, restored.text.length, {
|
||||
kind: 'session', id: reference.sessionId, label: title || reference.sessionId,
|
||||
path: '', isDirectory: false,
|
||||
})
|
||||
}
|
||||
setComposerInput(restored.text, restored.mentions)
|
||||
setComposerAttachments(uploads)
|
||||
clearWorkspaceReferences(activeTabId)
|
||||
}
|
||||
for (const attachment of contextAttachments) {
|
||||
addWorkspaceReference(activeTabId, {
|
||||
...attachment,
|
||||
path: attachment.path!,
|
||||
kind: attachment.diffSide || attachment.hunkId ? 'code-comment' : attachment.lineStart ? 'code-selection' : 'file',
|
||||
})
|
||||
}
|
||||
setPlusMenuOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
@@ -480,7 +509,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
|
||||
clearComposerPrefill(activeTabId, composerPrefill.nonce)
|
||||
}, [
|
||||
activeTabId,
|
||||
addWorkspaceReference,
|
||||
clearComposerPrefill,
|
||||
clearWorkspaceReferences,
|
||||
composerPrefill,
|
||||
setComposerAttachments,
|
||||
setComposerInput,
|
||||
|
||||
@@ -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: [],
|
||||
}, { restoreMissingSession: true }))
|
||||
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.replace('src/app.ts:L3-L5', '/repo/src/app.ts:L3-L5')}\n\nHow do I stop it?`)
|
||||
expect(attachments).toEqual([
|
||||
expect.objectContaining({ type: 'file', path: '/repo/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?' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,180 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { sessionsApi, type SessionRewindResponse } from '@/api/sessions'
|
||||
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 type { UserMessageEditDraft } from './userMessageEdit'
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
{ id: 'shared-user', type: 'user_text', content: 'continue', timestamp: 1 },
|
||||
{ id: 'reply', type: 'assistant_text', content: 'Done', timestamp: 2 },
|
||||
]
|
||||
const draft: UserMessageEditDraft = { text: 'edited prompt', attachments: [], sessionReferences: [] }
|
||||
const target = { targetUserMessageId: 'shared-user', userMessageIndex: 0, userMessageCount: 1 }
|
||||
const preview: SessionRewindResponse = {
|
||||
target, conversation: { messagesRemoved: 2 },
|
||||
code: { available: false, filesChanged: [], insertions: 0, deletions: 0 },
|
||||
}
|
||||
const codePreview: SessionRewindResponse = { ...preview, code: { available: true, filesChanged: ['app.ts'], insertions: 1, deletions: 0 } }
|
||||
const translate = (key: TranslationKey) => key
|
||||
|
||||
function makeSession(): PerSessionState {
|
||||
return {
|
||||
messages, chatState: 'idle', connectionState: 'connected', historyStatus: 'ready', historyHydrated: true,
|
||||
streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null,
|
||||
pendingPermission: null, pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 }, streamingResponseChars: 0, elapsedSeconds: 0,
|
||||
statusVerb: '', apiRetry: null, slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const setRewindingTurnId = vi.fn()
|
||||
return renderHook(({ sessionId }) => useUserMessageEditResend({
|
||||
sessionId, messages,
|
||||
turnCards: [{ target: { messageId: 'shared-user', expectedContent: 'continue' }, checkpoint: { target } }],
|
||||
disabled: false, rewindingTurnId: null, setRewindingTurnId, t: translate,
|
||||
}), { initialProps: { sessionId: 'session-a' } })
|
||||
}
|
||||
|
||||
describe('useUserMessageEditResend session and recovery boundaries', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({ ...useChatStore.getInitialState(), sessions: { 'session-a': makeSession(), 'session-b': makeSession() } }, true)
|
||||
useUIStore.setState({ toasts: [] })
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue(preview)
|
||||
vi.spyOn(useChatStore.getState(), 'sendMessage').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it.each([preview, codePreview])('ignores a pending preview after switching session instead of rewinding either session', async (response) => {
|
||||
const pending = deferred<SessionRewindResponse>()
|
||||
vi.mocked(sessionsApi.rewind).mockReturnValueOnce(pending.promise)
|
||||
const { result, rerender } = setup()
|
||||
act(() => result.current.editActionByMessageId.get('shared-user')!.onSubmit(draft))
|
||||
rerender({ sessionId: 'session-b' })
|
||||
await act(async () => { pending.resolve(response) })
|
||||
expect(result.current.dialog.open).toBe(false)
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
expect(useChatStore.getState().sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a captured editor submit action after switching session', async () => {
|
||||
const { result, rerender } = setup()
|
||||
const submitOld = result.current.editActionByMessageId.get('shared-user')!.onSubmit
|
||||
rerender({ sessionId: 'session-b' })
|
||||
await act(async () => submitOld(draft))
|
||||
expect(sessionsApi.rewind).not.toHaveBeenCalled()
|
||||
expect(useChatStore.getState().sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a stale preview even after switching back to its original session', async () => {
|
||||
const pending = deferred<SessionRewindResponse>()
|
||||
vi.mocked(sessionsApi.rewind).mockReturnValueOnce(pending.promise)
|
||||
const { result, rerender } = setup()
|
||||
act(() => result.current.editActionByMessageId.get('shared-user')!.onSubmit(draft))
|
||||
rerender({ sessionId: 'session-b' })
|
||||
rerender({ sessionId: 'session-a' })
|
||||
await act(async () => { pending.resolve(codePreview) })
|
||||
expect(result.current.dialog.open).toBe(false)
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides the old dialog and rejects its captured action after switching session', async () => {
|
||||
vi.mocked(sessionsApi.rewind).mockResolvedValue(codePreview)
|
||||
const { result, rerender } = setup()
|
||||
await act(async () => result.current.editActionByMessageId.get('shared-user')!.onSubmit(draft))
|
||||
expect(result.current.dialog.open).toBe(true)
|
||||
const confirmOld = result.current.dialog.actions[1]!.onClick
|
||||
rerender({ sessionId: 'session-b' })
|
||||
expect(result.current.dialog.open).toBe(false)
|
||||
await act(async () => confirmOld())
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('finishes an already committed rewind in its original session after navigation', async () => {
|
||||
const pending = deferred<SessionRewindResponse>()
|
||||
vi.mocked(sessionsApi.rewind).mockResolvedValueOnce(preview).mockReturnValueOnce(pending.promise)
|
||||
const reload = vi.spyOn(useChatStore.getState(), 'reloadHistory').mockResolvedValue(undefined)
|
||||
const { result, rerender } = setup()
|
||||
act(() => result.current.editActionByMessageId.get('shared-user')!.onSubmit(draft))
|
||||
await waitFor(() => expect(sessionsApi.rewind).toHaveBeenCalledTimes(2))
|
||||
rerender({ sessionId: 'session-b' })
|
||||
await act(async () => { pending.resolve(preview) })
|
||||
expect(reload).toHaveBeenCalledWith('session-a', expect.objectContaining({ messages }), { requireApplied: true })
|
||||
expect(useChatStore.getState().sendMessage).toHaveBeenCalledWith('session-a', draft.text, [], expect.any(Object))
|
||||
})
|
||||
|
||||
it('recovers the draft when its source tab closes during the committed rewind', async () => {
|
||||
vi.spyOn(sessionsApi, 'getFullHistory').mockResolvedValue({ messages: [] })
|
||||
const pending = deferred<SessionRewindResponse>()
|
||||
vi.mocked(sessionsApi.rewind).mockResolvedValueOnce(preview).mockReturnValueOnce(pending.promise)
|
||||
const withContext: UserMessageEditDraft = {
|
||||
...draft,
|
||||
attachments: [{ id: 'file', type: 'file', name: 'app.ts', path: '/repo/app.ts', lineStart: 3, quote: 'value', sendable: true }],
|
||||
sessionReferences: [{ sessionId: 'referenced-session' }],
|
||||
}
|
||||
const { result, unmount } = setup()
|
||||
act(() => result.current.editActionByMessageId.get('shared-user')!.onSubmit(withContext))
|
||||
await waitFor(() => expect(sessionsApi.rewind).toHaveBeenCalledTimes(2))
|
||||
act(() => useChatStore.getState().disconnectSession('session-a'))
|
||||
unmount()
|
||||
await act(async () => { pending.resolve(preview) })
|
||||
expect(useChatStore.getState().sendMessage).not.toHaveBeenCalled()
|
||||
expect(useChatStore.getState().sessions['session-a']?.composerPrefill).toMatchObject({
|
||||
text: draft.text, attachments: withContext.attachments, sessionReferences: withContext.sessionReferences,
|
||||
})
|
||||
expect(useChatStore.getState().sessions['session-b']?.composerPrefill).toBeUndefined()
|
||||
})
|
||||
|
||||
it('isolates drafts in branched sessions that share a transcript message id', () => {
|
||||
const { result, rerender } = setup()
|
||||
act(() => {
|
||||
const action = result.current.editActionByMessageId.get('shared-user')!
|
||||
action.onStart()
|
||||
action.onDraftChange(draft)
|
||||
})
|
||||
rerender({ sessionId: 'session-b' })
|
||||
expect(result.current.editActionByMessageId.get('shared-user')!.editing).toBe(false)
|
||||
expect(result.current.editActionByMessageId.get('shared-user')!.getDraft().text).toBe('continue')
|
||||
rerender({ sessionId: 'session-a' })
|
||||
expect(result.current.editActionByMessageId.get('shared-user')!.getDraft().text).toBe(draft.text)
|
||||
})
|
||||
|
||||
it('hands the complete draft to the composer when the real history reload fails', async () => {
|
||||
vi.spyOn(sessionsApi, 'getFullHistory').mockRejectedValue(new Error('history offline'))
|
||||
const withContext: UserMessageEditDraft = {
|
||||
...draft,
|
||||
attachments: [{ id: 'file', type: 'file', name: 'app.ts', path: '/repo/app.ts', lineStart: 3, lineEnd: 5, quote: 'for (;;) {}', sendable: true }],
|
||||
sessionReferences: [{ sessionId: 'referenced-session' }],
|
||||
}
|
||||
const { result } = setup()
|
||||
await act(async () => result.current.editActionByMessageId.get('shared-user')!.onSubmit(withContext))
|
||||
expect(useChatStore.getState().sendMessage).not.toHaveBeenCalled()
|
||||
expect(useChatStore.getState().sessions['session-a']!.composerPrefill).toMatchObject({
|
||||
text: draft.text, attachments: withContext.attachments, sessionReferences: withContext.sessionReferences,
|
||||
})
|
||||
expect(useUIStore.getState().toasts).toContainEqual(expect.objectContaining({ type: 'warning' }))
|
||||
})
|
||||
|
||||
it('ignores a failed preview after the editor unmounts', async () => {
|
||||
const pending = deferred<SessionRewindResponse>()
|
||||
vi.mocked(sessionsApi.rewind).mockReturnValueOnce(pending.promise)
|
||||
const { result, unmount } = setup()
|
||||
act(() => result.current.editActionByMessageId.get('shared-user')!.onSubmit(draft))
|
||||
unmount()
|
||||
await act(async () => { pending.reject(new Error('late failure')) })
|
||||
expect(useUIStore.getState().toasts).toEqual([])
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useCallback, useEffect, 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 EditContext = { sessionId: string | null | undefined; active: boolean }
|
||||
|
||||
type PendingConfirm = {
|
||||
context: EditContext
|
||||
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 [editing, setEditing] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||
const [submitting, setSubmitting] = useState<{ context: EditContext; messageId: string } | null>(null)
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | null>(null)
|
||||
const draftsRef = useRef(new Map<string, UserMessageEditDraft>())
|
||||
// A new identity also invalidates A → B → A requests and captured dialog actions.
|
||||
const contextRef = useRef<EditContext>({ sessionId, active: true })
|
||||
if (contextRef.current.sessionId !== sessionId) contextRef.current = { sessionId, active: true }
|
||||
const context = contextRef.current
|
||||
const editingMessageId = editing && editing.sessionId === sessionId ? editing.messageId : null
|
||||
const submittingMessageId = submitting?.context === context ? submitting.messageId : null
|
||||
const isCurrentContext = useCallback((candidate: EditContext) =>
|
||||
candidate.active && contextRef.current === candidate, [])
|
||||
|
||||
useEffect(() => {
|
||||
const current = contextRef.current
|
||||
current.active = true
|
||||
setRewindingTurnId(null)
|
||||
return () => { current.active = false }
|
||||
}, [sessionId, setRewindingTurnId])
|
||||
|
||||
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((targetSessionId: string, messageId: string) => {
|
||||
draftsRef.current.delete(JSON.stringify([targetSessionId, messageId]))
|
||||
setEditing((current) => current?.sessionId === targetSessionId && current.messageId === messageId ? null : current)
|
||||
}, [])
|
||||
|
||||
const runResend = useCallback(async (
|
||||
requestContext: EditContext,
|
||||
messageId: string,
|
||||
card: EditableTurnCard,
|
||||
draft: UserMessageEditDraft,
|
||||
payload: UserMessageResendPayload,
|
||||
mode: SessionRewindMode,
|
||||
) => {
|
||||
const targetSessionId = requestContext.sessionId
|
||||
if (!targetSessionId || !isCurrentContext(requestContext)) return
|
||||
const addToast = useUIStore.getState().addToast
|
||||
setRewindingTurnId(messageId)
|
||||
let result: SessionRewindResponse
|
||||
try {
|
||||
result = await rewindToTurnCheckpoint(targetSessionId, {
|
||||
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) }) })
|
||||
if (isCurrentContext(requestContext)) {
|
||||
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(targetSessionId, messageId)
|
||||
if (isCurrentContext(requestContext)) setConfirm(null)
|
||||
const chatStore = useChatStore.getState()
|
||||
let sent = false
|
||||
try {
|
||||
const session = chatStore.sessions[targetSessionId]
|
||||
await chatStore.reloadHistory(targetSessionId, {
|
||||
messages: session?.messages ?? [],
|
||||
backgroundAgentTasks: session?.backgroundAgentTasks,
|
||||
}, { requireApplied: true })
|
||||
if (useChatStore.getState().sessions[targetSessionId]?.chatState === 'idle') {
|
||||
useChatStore.getState().sendMessage(targetSessionId, payload.content, payload.attachments, payload.options)
|
||||
sent = true
|
||||
}
|
||||
} catch {
|
||||
sent = false
|
||||
}
|
||||
if (!sent) {
|
||||
useChatStore.getState().queueComposerPrefill(targetSessionId, {
|
||||
text: draft.text,
|
||||
attachments: draft.attachments.filter((attachment) => attachment.sendable),
|
||||
...(draft.sessionReferences.length > 0 ? { sessionReferences: draft.sessionReferences } : {}),
|
||||
}, { restoreMissingSession: true })
|
||||
addToast({ type: 'warning', message: t('chat.editResendPrefilled') })
|
||||
} else if (mode === 'both') {
|
||||
addToast(describeRewindResult(result, mode, t))
|
||||
}
|
||||
if (isCurrentContext(requestContext)) setRewindingTurnId(null)
|
||||
}, [closeEditor, isCurrentContext, setRewindingTurnId, t])
|
||||
|
||||
const submit = useCallback(async (messageId: string, draft: UserMessageEditDraft) => {
|
||||
const requestContext = context
|
||||
if (!isCurrentContext(requestContext)) return
|
||||
const card = editableCards.get(messageId)
|
||||
if (!sessionId || !card || disabled || submittingMessageId || rewindingTurnId) return
|
||||
const payload = buildUserMessageResendPayload(draft, { contextReferencesOnly })
|
||||
if (!payload) return
|
||||
draftsRef.current.set(JSON.stringify([sessionId, messageId]), draft)
|
||||
|
||||
setSubmitting({ context: requestContext, 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) {
|
||||
if (!isCurrentContext(requestContext)) return
|
||||
useUIStore.getState().addToast({
|
||||
type: 'error',
|
||||
message: t('chat.editResendFailed', { detail: getApiErrorMessage(error) }),
|
||||
})
|
||||
setSubmitting(null)
|
||||
return
|
||||
}
|
||||
if (!isCurrentContext(requestContext)) return
|
||||
setSubmitting(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(requestContext, messageId, card, draft, payload, 'conversation')
|
||||
return
|
||||
}
|
||||
setConfirm({ context: requestContext, messageId, card, draft, payload, preview, laterTurns, canRestoreCode })
|
||||
}, [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) {
|
||||
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(JSON.stringify([sessionId, messageId])) ?? createUserMessageEditDraft(message),
|
||||
onStart: () => {
|
||||
setEditing((current) => {
|
||||
if (current && (current.sessionId !== sessionId || current.messageId !== messageId)) {
|
||||
draftsRef.current.delete(JSON.stringify([current.sessionId, current.messageId]))
|
||||
}
|
||||
return { sessionId, messageId }
|
||||
})
|
||||
},
|
||||
onCancel: () => closeEditor(sessionId, messageId),
|
||||
onDraftChange: (draft) => { draftsRef.current.set(JSON.stringify([sessionId, 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 || !isCurrentContext(confirm.context)) {
|
||||
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.context, 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, context, isCurrentContext, rewindingTurnId, runResend, t])
|
||||
|
||||
return { editActionByMessageId, dialog }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UIAttachment, UIMessage } from '../../types/chat'
|
||||
import { formatWorkspaceReferencePrompt, type WorkspaceChatReference } from '../../stores/workspaceChatContextStore'
|
||||
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: '/repo/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.each([false, true])('preserves distinct suffix paths from composer references (loaded: %s)', (loaded) => {
|
||||
const reference: WorkspaceChatReference = {
|
||||
id: 'root-selection', kind: 'code-selection', name: 'config.ts',
|
||||
path: 'config.ts', absolutePath: '/repo/config.ts',
|
||||
lineStart: 1, lineEnd: 1, quote: 'root config',
|
||||
}
|
||||
const referencePrompt = formatWorkspaceReferencePrompt([reference])
|
||||
// ChatInput prefixes uploads before the absolute workspace reference paths.
|
||||
// Previously the display parser consumed the upload as the selection and
|
||||
// editing permanently dropped that distinct file from the model payload.
|
||||
const liveAttachments: UIAttachment[] = [
|
||||
{ type: 'file', name: 'config.ts', path: '/repo/sub/config.ts', mimeType: 'text/typescript' },
|
||||
{ type: 'file', name: reference.name, path: reference.path,
|
||||
lineStart: reference.lineStart, lineEnd: reference.lineEnd, quote: reference.quote },
|
||||
]
|
||||
const draft = createUserMessageEditDraft({
|
||||
content: 'Compare these',
|
||||
modelContent: `@"/repo/sub/config.ts" @"${reference.absolutePath}" ${referencePrompt}\n\nCompare these`,
|
||||
// The old history display parser has already omitted the wrong prefix.
|
||||
attachments: loaded
|
||||
? [{ type: 'file', name: 'config.ts', path: '/repo/config.ts' }, liveAttachments[1]!]
|
||||
: liveAttachments,
|
||||
})
|
||||
const payload = buildUserMessageResendPayload(draft, labels)!
|
||||
expect(payload.attachments.map(attachment => attachment.path))
|
||||
.toEqual(['/repo/sub/config.ts', '/repo/config.ts'])
|
||||
expect(payload.attachments[1]).toMatchObject({ lineStart: 1, quote: 'root config' })
|
||||
if (!loaded) expect(payload.attachments[0]?.mimeType).toBe('text/typescript')
|
||||
})
|
||||
|
||||
it('keeps separate selections and their original diff metadata on one file', () => {
|
||||
const references: WorkspaceChatReference[] = [
|
||||
{ id: 'first', kind: 'code-comment', path: 'src/config.ts', absolutePath: '/repo/src/config.ts',
|
||||
name: 'config.ts', lineStart: 2, diffSide: 'old', hunkId: 'hunk-first', note: ' first ', quote: ' first quote ' },
|
||||
{ id: 'second', kind: 'code-comment', path: 'src/config.ts', absolutePath: '/repo/src/config.ts',
|
||||
name: 'config.ts', lineStart: 8, diffSide: 'new', hunkId: 'hunk-second', quote: 'second quote' },
|
||||
]
|
||||
const draft = createUserMessageEditDraft({
|
||||
content: 'Review',
|
||||
modelContent: `@"/repo/other/src/config.ts" @"/repo/src/config.ts" @"/repo/src/config.ts" ${formatWorkspaceReferencePrompt(references)}\n\nReview`,
|
||||
attachments: [
|
||||
{ type: 'file', name: 'config.ts', path: '/repo/other/src/config.ts' },
|
||||
...references.map((reference): UIAttachment => ({
|
||||
type: 'file', name: reference.name, path: reference.path, lineStart: reference.lineStart,
|
||||
diffSide: reference.diffSide, hunkId: reference.hunkId, note: reference.note, quote: reference.quote,
|
||||
})),
|
||||
],
|
||||
})
|
||||
expect(draft.attachments.map(attachment => attachment.path))
|
||||
.toEqual(['/repo/other/src/config.ts', '/repo/src/config.ts', '/repo/src/config.ts'])
|
||||
expect(draft.attachments[1]).toMatchObject({ hunkId: 'hunk-first', lineStart: 2, diffSide: 'old' })
|
||||
expect(draft.attachments[2]).toMatchObject({ hunkId: 'hunk-second', lineStart: 8, diffSide: 'new' })
|
||||
})
|
||||
|
||||
it('merges exact original attachment metadata instead of losing image data and directory identity', () => {
|
||||
const draft = createUserMessageEditDraft({
|
||||
content: 'Look',
|
||||
modelContent: '@"/repo/shot.png" @"/repo/assets" Look',
|
||||
attachments: [
|
||||
{ type: 'image', name: 'Original screenshot', path: '/repo/shot.png', data: 'data:image/png;base64,AAAA', mimeType: 'image/png' },
|
||||
{ type: 'file', name: 'assets', path: '/repo/assets', isDirectory: true },
|
||||
],
|
||||
})
|
||||
expect(draft.attachments).toHaveLength(2)
|
||||
expect(draft.attachments[0]).toMatchObject({ name: 'Original screenshot', data: 'data:image/png;base64,AAAA', mimeType: 'image/png' })
|
||||
expect(draft.attachments[1]).toMatchObject({ isDirectory: true })
|
||||
expect(buildUserMessageResendPayload(draft, labels)?.attachments[0])
|
||||
.toEqual({ type: 'image', name: 'Original screenshot', mimeType: 'image/png', data: 'data:image/png;base64,AAAA' })
|
||||
})
|
||||
|
||||
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.replace('src/app.ts', '/repo/src/app.ts')}\n\nPlease delete it instead`)
|
||||
expect(payload.attachments).toEqual([
|
||||
expect.objectContaining({ type: 'file', path: '/repo/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: `@"/repo/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,261 @@
|
||||
import { splitSessionReferenceContext } from '@/lib/sessionReferences'
|
||||
import {
|
||||
extractLeadingFileReferences,
|
||||
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)
|
||||
}
|
||||
|
||||
function normalizeAttachmentPath(path: string | undefined): string | undefined {
|
||||
return path?.replace(/\\/g, '/').replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
function referenceContextKey(attachment: UIAttachment): string {
|
||||
return JSON.stringify([
|
||||
attachment.lineStart,
|
||||
attachment.lineEnd ?? attachment.lineStart,
|
||||
attachment.diffSide,
|
||||
attachment.note?.trim() || undefined,
|
||||
attachment.quote?.trim() || undefined,
|
||||
])
|
||||
}
|
||||
|
||||
type AttachmentCandidate = { attachment: UIAttachment; paths: Array<string | undefined> }
|
||||
|
||||
function reconstructAttachments(
|
||||
leading: UIAttachment[],
|
||||
workspace: UIAttachment[],
|
||||
originals: UIAttachment[],
|
||||
): UIAttachment[] {
|
||||
const unmatchedLeading = leading.map((attachment) => ({ attachment, paths: [attachment.path] }))
|
||||
const references: AttachmentCandidate[] = workspace.map((attachment) => ({ attachment, paths: [attachment.path] }))
|
||||
const findLeading = (path: string | undefined, exact: boolean) => {
|
||||
for (let index = unmatchedLeading.length - 1; index >= 0; index -= 1) {
|
||||
const candidatePath = unmatchedLeading[index]!.attachment.path
|
||||
if (exact
|
||||
? normalizeAttachmentPath(candidatePath) === normalizeAttachmentPath(path)
|
||||
: pathsReferToSameFile(candidatePath, path)
|
||||
) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// The composer sends upload paths first, then workspace paths in reference
|
||||
// order. Match from the end so a root-file selection cannot consume an upload
|
||||
// with the same suffix; retain every unmatched absolute transport path.
|
||||
for (let index = references.length - 1; index >= 0; index -= 1) {
|
||||
const reference = references[index]!
|
||||
let matchingIndex = findLeading(reference.attachment.path, true)
|
||||
if (matchingIndex < 0) matchingIndex = findLeading(reference.attachment.path, false)
|
||||
if (matchingIndex >= 0) {
|
||||
const [matched] = unmatchedLeading.splice(matchingIndex, 1)
|
||||
reference.paths.push(matched!.attachment.path)
|
||||
reference.attachment = { ...reference.attachment, path: matched!.attachment.path }
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: AttachmentCandidate[] = [...unmatchedLeading, ...references]
|
||||
const unmatchedOriginals = originals.filter((attachment) => attachment.referenceKind !== 'chat-selection')
|
||||
for (const candidate of candidates) {
|
||||
const matchingIndex = unmatchedOriginals.findIndex((original) =>
|
||||
original.path && candidate.paths.some((path) =>
|
||||
normalizeAttachmentPath(path) === normalizeAttachmentPath(original.path),
|
||||
) && referenceContextKey(original) === referenceContextKey(candidate.attachment),
|
||||
)
|
||||
if (matchingIndex < 0) continue
|
||||
const [original] = unmatchedOriginals.splice(matchingIndex, 1)
|
||||
const metadata = Object.fromEntries(Object.entries(original!).filter(([, value]) => value !== undefined))
|
||||
candidate.attachment = { ...candidate.attachment, ...metadata, path: candidate.attachment.path }
|
||||
}
|
||||
// The display parser may already have lost a prefix on a reloaded message.
|
||||
// Its remaining bare chip is redundant with a recovered canonical path, but
|
||||
// distinct absolute paths and data-only uploads must survive.
|
||||
const extraAttachments = unmatchedOriginals.filter((original) =>
|
||||
!original.path || !candidates.some((candidate) => candidate.paths.some((path) =>
|
||||
normalizeAttachmentPath(path) === normalizeAttachmentPath(original.path),
|
||||
)),
|
||||
)
|
||||
return [...candidates.map((candidate) => candidate.attachment), ...extraAttachments]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 sanitized = hasImage ? stripGeneratedImageMetadataLines(source) : source
|
||||
const referenceContext = splitSessionReferenceContext(sanitized)
|
||||
// Parse transport prefixes separately: the history display reader otherwise
|
||||
// deduplicates absolute paths against relative workspace paths by suffix.
|
||||
const leading = extractLeadingFileReferences(referenceContext.content)
|
||||
const parsed = extractRestoredUserDisplay(leading.content)
|
||||
const attachments = reconstructAttachments(leading.attachments ?? [], parsed.attachments ?? [], messageAttachments)
|
||||
.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 ?? referenceContext.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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '会話が長すぎるため、ターンごとのプレビューやファイルの取り消しは利用できません。この一覧は現在のワークスペース全体の変更を示し、他のターンやこの会話外の変更を含む場合があります。',
|
||||
|
||||
@@ -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': '대화가 너무 길어 턴별 미리보기나 파일 실행 취소를 사용할 수 없습니다. 이 목록은 현재 워크스페이스의 누적 변경을 표시하며 다른 턴이나 이 대화 밖의 변경도 포함할 수 있습니다.',
|
||||
|
||||
@@ -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': '對話過長,無法提供逐輪預覽或檔案復原。此清單顯示目前工作區的累計變更,可能包含其他輪次或對話外的變更。',
|
||||
|
||||
@@ -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': '会话过长,无法提供逐轮预览或文件撤销。此列表显示当前工作区的累计改动,可能包含其他轮次或会话外的改动。',
|
||||
|
||||
@@ -16778,3 +16778,148 @@ describe('chatStore inactive complete-page retention', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Edit-and-resend must not send on a swallowed error or an obsolete response:
|
||||
// the old turn has already been removed from the server transcript.
|
||||
describe('chatStore strict history reload', () => {
|
||||
const sessionId = 'strict-history-reload'
|
||||
const strict = { requireApplied: true }
|
||||
const oldMessages: UIMessage[] = [
|
||||
{ id: 'rewound-user', type: 'user_text', content: 'Removed prompt', timestamp: 1 },
|
||||
{ id: 'rewound-reply', type: 'assistant_text', content: 'Removed reply', timestamp: 2 },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(sessionsApi.getFullHistory).mockReset()
|
||||
vi.mocked(sessionsApi.getFullHistory).mockResolvedValue({ messages: [] })
|
||||
useChatStore.setState({
|
||||
...initialState,
|
||||
sessions: { [sessionId]: makeSession({ chatState: 'idle', historyHydrated: true, messages: oldMessages }) },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a failed fetch instead of treating stale messages as refreshed', async () => {
|
||||
const failure = new Error('History unavailable after rewind')
|
||||
vi.mocked(sessionsApi.getFullHistory).mockRejectedValueOnce(failure)
|
||||
await expect(useChatStore.getState().reloadHistory(sessionId, undefined, strict)).rejects.toBe(failure)
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toBe(oldMessages)
|
||||
})
|
||||
|
||||
it('keeps best-effort reload failures compatible for existing callers', async () => {
|
||||
vi.mocked(sessionsApi.getFullHistory).mockRejectedValueOnce(new Error('History unavailable'))
|
||||
await expect(useChatStore.getState().reloadHistory(sessionId)).resolves.toBeUndefined()
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toBe(oldMessages)
|
||||
})
|
||||
|
||||
it('resolves once complete authoritative history replaces the removed turn', async () => {
|
||||
await expect(useChatStore.getState().reloadHistory(sessionId, undefined, strict)).resolves.toBeUndefined()
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toEqual([])
|
||||
expect(useChatStore.getState().sessions[sessionId]?.historyStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('rejects a superseded response even when the transport ignores abort', async () => {
|
||||
let resolveFirst!: (value: { messages: MessageEntry[] }) => void
|
||||
vi.mocked(sessionsApi.getFullHistory).mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
|
||||
const rejected = expect(useChatStore.getState().reloadHistory(sessionId, undefined, strict)).rejects.toThrow('History reload was not applied')
|
||||
await useChatStore.getState().reloadHistory(sessionId)
|
||||
resolveFirst({ messages: [{ id: 'stale', type: 'user', content: 'Stale', timestamp: '2026-10-03T00:00:00Z' }] })
|
||||
await rejected
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a response for a disconnected session without recreating it', async () => {
|
||||
let resolveHistory!: (value: { messages: MessageEntry[] }) => void
|
||||
vi.mocked(sessionsApi.getFullHistory).mockReturnValueOnce(new Promise((resolve) => { resolveHistory = resolve }))
|
||||
const rejected = expect(useChatStore.getState().reloadHistory(sessionId, undefined, strict)).rejects.toThrow('History reload was not applied')
|
||||
useChatStore.getState().disconnectSession(sessionId)
|
||||
resolveHistory({ messages: [] })
|
||||
await rejected
|
||||
expect(useChatStore.getState().sessions[sessionId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a guarded reload when a live prompt changes the conversation', async () => {
|
||||
let resolveHistory!: (value: { messages: MessageEntry[] }) => void
|
||||
vi.mocked(sessionsApi.getFullHistory).mockReturnValueOnce(new Promise((resolve) => { resolveHistory = resolve }))
|
||||
const rejected = expect(useChatStore.getState().reloadHistory(sessionId, { messages: oldMessages }, strict)).rejects.toThrow('History reload was not applied')
|
||||
useChatStore.getState().handleServerMessage(sessionId, { type: 'user_message_replay', content: 'Competing prompt' })
|
||||
const liveMessages = useChatStore.getState().sessions[sessionId]?.messages
|
||||
resolveHistory({ messages: [] })
|
||||
await rejected
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toBe(liveMessages)
|
||||
expect(liveMessages).toContainEqual(expect.objectContaining({ content: 'Competing prompt' }))
|
||||
})
|
||||
|
||||
it('rejects incomplete history instead of retaining removed rows as authoritative', async () => {
|
||||
vi.mocked(sessionsApi.getFullHistory).mockResolvedValueOnce({ messages: [], page: {
|
||||
nextCursor: 'older', hasMore: true, historyComplete: false,
|
||||
sourceVersion: 'version', scannedBytes: 2, omittedOversizedEntries: 1,
|
||||
} })
|
||||
await expect(useChatStore.getState().reloadHistory(sessionId, undefined, strict)).rejects.toThrow('History reload was not applied')
|
||||
expect(useChatStore.getState().sessions[sessionId]?.messages).toBe(oldMessages)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatStore composer prefill references', () => {
|
||||
const sessionId = 'prefill-references'
|
||||
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({ ...initialState, sessions: { [sessionId]: makeSession({ chatState: 'idle' }) } })
|
||||
})
|
||||
|
||||
it('retains session context with an edited prompt handed to the composer', () => {
|
||||
const sessionReferences = [{ sessionId: 'referenced-session' }]
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, { text: 'Edited prompt', sessionReferences })
|
||||
expect(useChatStore.getState().sessions[sessionId]?.composerPrefill).toMatchObject({
|
||||
text: 'Edited prompt', sessionReferences,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the existing prefill shape when no session references were supplied', () => {
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, { text: 'Existing prefill' })
|
||||
expect(useChatStore.getState().sessions[sessionId]?.composerPrefill).not.toHaveProperty('sessionReferences')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// A completed rewind must retain its edit even when the source tab was closed
|
||||
// while the API call was pending; delayed ordinary prefills must stay ignored.
|
||||
describe('chatStore closed-session edit recovery', () => {
|
||||
const sessionId = 'closed-edit-recovery'
|
||||
const prefill = {
|
||||
text: 'Recovered edit',
|
||||
attachments: [{ type: 'file' as const, name: 'app.ts', path: '/repo/app.ts' }],
|
||||
sessionReferences: [{ sessionId: 'context-session' }],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useChatStore.setState({ ...initialState, sessions: {} })
|
||||
})
|
||||
|
||||
it('ignores a normal delayed prefill for a missing session', () => {
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, prefill)
|
||||
expect(useChatStore.getState().sessions[sessionId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains an explicitly recovered edit without opening a socket or sending it', async () => {
|
||||
const { wsManager } = await import('../api/websocket')
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, prefill, { restoreMissingSession: true })
|
||||
expect(useChatStore.getState().sessions[sessionId]).toMatchObject({
|
||||
connectionState: 'disconnected', chatState: 'idle', messages: [], composerPrefill: prefill,
|
||||
})
|
||||
expect(wsManager.connect).not.toHaveBeenCalled()
|
||||
expect(wsManager.send).not.toHaveBeenCalled()
|
||||
expect(sessionsApi.getFullHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the recovered prefill when the user reopens its disconnected session', () => {
|
||||
useChatStore.setState({ sessions: { [sessionId]: makeSession({ connectionState: 'disconnected', chatState: 'idle' }) } })
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, prefill)
|
||||
const recovered = useChatStore.getState().sessions[sessionId]?.composerPrefill
|
||||
useChatStore.getState().connectToSession(sessionId, { minimalBootstrap: true, prewarm: false, applyRuntimeSelection: false })
|
||||
expect(useChatStore.getState().sessions[sessionId]?.composerPrefill).toBe(recovered)
|
||||
expect(useChatStore.getState().sessions[sessionId]?.connectionState).toBe('connecting')
|
||||
expect(sessionsApi.getFullHistory).not.toHaveBeenCalled()
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,6 +209,7 @@ export type PerSessionState = {
|
||||
composerPrefill?: {
|
||||
text: string
|
||||
attachments?: UIAttachment[]
|
||||
sessionReferences?: Array<{ sessionId: string }>
|
||||
mode?: ComposerPrefillMode
|
||||
nonce: number
|
||||
} | null
|
||||
@@ -442,10 +443,19 @@ type ChatStore = {
|
||||
messages: UIMessage[]
|
||||
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
||||
},
|
||||
/** Reject errors and skipped/incomplete reloads after a destructive rewind. */
|
||||
options?: { requireApplied?: boolean },
|
||||
) => Promise<void>
|
||||
queueComposerPrefill: (
|
||||
sessionId: string,
|
||||
prefill: { text: string; attachments?: UIAttachment[]; mode?: ComposerPrefillMode },
|
||||
prefill: {
|
||||
text: string
|
||||
attachments?: UIAttachment[]
|
||||
sessionReferences?: Array<{ sessionId: string }>
|
||||
mode?: ComposerPrefillMode
|
||||
},
|
||||
/** Retain an edit after its committed rewind outlives the source tab. */
|
||||
options?: { restoreMissingSession?: boolean },
|
||||
) => void
|
||||
clearComposerPrefill: (sessionId: string, nonce?: number) => void
|
||||
queueComposerInsertion: (
|
||||
@@ -3043,6 +3053,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
historyBootstrapDisabled: options?.minimalBootstrap === true,
|
||||
activeGoal: existing?.activeGoal ?? null,
|
||||
composerDraft: existing?.composerDraft ?? null,
|
||||
composerPrefill: existing?.composerPrefill ?? null,
|
||||
repositoryLaunchDraft: existing?.repositoryLaunchDraft ?? null,
|
||||
queuedUserMessages: existing?.queuedUserMessages ?? [],
|
||||
backgroundAgentTasks: existing?.backgroundAgentTasks ?? {},
|
||||
@@ -4072,8 +4083,11 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
await loadOlderHistoryPage(sessionId, get, set)
|
||||
},
|
||||
|
||||
reloadHistory: async (sessionId, guard) => {
|
||||
if (isSideChatSession(sessionId)) return
|
||||
reloadHistory: async (sessionId, guard, options) => {
|
||||
const skipReload = () => {
|
||||
if (options?.requireApplied) throw new Error('History reload was not applied')
|
||||
}
|
||||
if (isSideChatSession(sessionId)) return skipReload()
|
||||
if (historyPageControllers.has(sessionId)) {
|
||||
historyPageControllers.get(sessionId)?.abort()
|
||||
historyPageControllers.delete(sessionId)
|
||||
@@ -4092,7 +4106,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
if (pendingLoad?.lifecycleGeneration === lifecycleGeneration) {
|
||||
await pendingLoad.promise
|
||||
}
|
||||
if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration) || controller.signal.aborted) return
|
||||
if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration) || controller.signal.aborted) return skipReload()
|
||||
historyRecoveryControllers.get(sessionId)?.abort()
|
||||
historyRecoveryControllers.delete(sessionId)
|
||||
// A reload can queue behind a cold load. Capture snapshot baselines only
|
||||
@@ -4125,12 +4139,15 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
sessionOwnedActivityToolUseIds(get().sessions[sessionId]),
|
||||
controller.signal,
|
||||
)
|
||||
// A partial reload merges existing rows. It cannot prove that a rewound
|
||||
// turn disappeared, so strict callers must keep their draft for retry.
|
||||
if (options?.requireApplied && !historyComplete) return skipReload()
|
||||
durableHistoryRows.set(sessionId, new WeakSet(uiMessages))
|
||||
|
||||
if (
|
||||
!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration) ||
|
||||
historyReloadGenerations.get(sessionId) !== reloadGeneration
|
||||
) return
|
||||
) return skipReload()
|
||||
|
||||
if (guard) {
|
||||
const current = get().sessions[sessionId]
|
||||
@@ -4139,7 +4156,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
current.chatState !== 'idle' ||
|
||||
(current.historyMutationEpoch ?? 0) !== requestedMutationEpoch
|
||||
) {
|
||||
return
|
||||
return skipReload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4242,7 +4259,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!historyApplied) return
|
||||
if (!historyApplied) return skipReload()
|
||||
if (!historyComplete && page) void recoverSessionHistory(sessionId, page.sourceVersion)
|
||||
const terminalReconnectBoundary = terminalReconnectHistoryBoundaries.get(sessionId)
|
||||
if (
|
||||
@@ -4306,7 +4323,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
requestedTasks,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A stop failure can arrive before the task history that identifies it.
|
||||
// If that history request fails, surface the failure instead of leaving it
|
||||
// cached forever waiting for a reconciliation that may never happen.
|
||||
@@ -4328,22 +4345,34 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
)),
|
||||
}
|
||||
})
|
||||
if (options?.requireApplied) throw error
|
||||
} finally {
|
||||
if (historyReloadControllers.get(sessionId) === controller) historyReloadControllers.delete(sessionId)
|
||||
}
|
||||
},
|
||||
|
||||
queueComposerPrefill: (sessionId, prefill) => {
|
||||
set((state) => ({
|
||||
sessions: updateSessionIn(state.sessions, sessionId, () => ({
|
||||
composerPrefill: {
|
||||
text: prefill.text,
|
||||
attachments: prefill.attachments,
|
||||
mode: prefill.mode,
|
||||
nonce: Date.now(),
|
||||
queueComposerPrefill: (sessionId, prefill, options) => {
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId] ?? (
|
||||
options?.restoreMissingSession ? createDefaultSessionState() : undefined
|
||||
)
|
||||
if (!session) return state
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionId]: {
|
||||
...session,
|
||||
composerPrefill: {
|
||||
text: prefill.text,
|
||||
attachments: prefill.attachments,
|
||||
...(prefill.sessionReferences ? { sessionReferences: prefill.sessionReferences } : {}),
|
||||
mode: prefill.mode,
|
||||
nonce: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearComposerPrefill: (sessionId, nonce) => {
|
||||
@@ -7235,7 +7264,7 @@ function getReferenceName(referencePath: string): string {
|
||||
return name || referencePath
|
||||
}
|
||||
|
||||
function extractLeadingFileReferences(text: string): {
|
||||
export function extractLeadingFileReferences(text: string): {
|
||||
content: string
|
||||
attachments?: UIAttachment[]
|
||||
modelContent?: string
|
||||
@@ -7357,7 +7386,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 +7404,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 }
|
||||
|
||||
@@ -80,6 +80,22 @@ Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件
|
||||
|
||||
如果某一轮的文件检查点本身残缺(会话记录损坏、路径不安全等),代码就还原不了了,确认框里只剩「只回滚对话」——对话永远能退回去。
|
||||
|
||||
### 编辑历史消息并重新运行
|
||||
|
||||
想改一下某条提示词再跑一遍(比如回复被截断、需求没说清),把鼠标移到自己发过的消息上,点铅笔图标「编辑并重新发送」。消息会原地变成编辑框,原来的附件和引用以标签形式保留,可以删掉不要的;按 Enter 发送(跟随设置里的发送方式),Esc 取消。取消时会话不会有任何变化。
|
||||
|
||||
能编辑的消息和能回滚的轮次是同一批:已经完成的轮次,包括失败或中断的轮次。助手还在运行、有后台任务、子 Agent 或团队成员会话、侧边对话里不提供编辑。
|
||||
|
||||
发送后会把会话回滚到这条消息之前,再发送修改后的内容,回滚方式和上面的撤销完全一样:
|
||||
|
||||
- 只是最近一轮、也没有可还原的文件改动时,直接回滚对话并发送,不再确认。
|
||||
- 编辑更早的消息时,确认框会说明这条消息之后有几轮对话会被删除。
|
||||
- 从这一轮开始有可还原的文件改动时,可以选「回滚代码和对话并发送」,或「仅回滚对话并发送」(磁盘上的文件保持现状)。文件检查点残缺时只剩后者。
|
||||
|
||||
如果回滚没成功,对话和文件都不会变,编辑框保留你写的内容。如果回滚成功但消息没能自动发出,修改后的内容会放回底部输入框,不会丢。
|
||||
|
||||
编辑一条发生在上下文压缩之前的消息时,会话会退回到压缩之前,重新运行时用的是完整的原始上下文。
|
||||
|
||||
## 活动面板:它现在在忙什么
|
||||
|
||||
标签栏右侧第一个按钮打开活动面板,里面按区块列出这条会话的所有并行工作:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user