mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: make current turn changes reviewable and undoable
Workspace inspection was visible only as a side panel, while file restore still lived behind the older per-message rewind affordance. This adds a chat-flow change card for the completed turn, keeps undo routed through checkpoint rewind, and tightens the workspace split layout so narrow and fullscreen windows keep both chat and file preview usable. Constraint: Current session file changes must work for non-Git directories when transcript tool edits are available Constraint: Workspace preview must remain right-docked without horizontal overflow across narrow and wide desktop viewports Rejected: Keep only hover rewind | users could not clearly see what the current turn changed before undoing it Rejected: Use Git as the only changed-file source | many desktop sessions run in temporary or non-Git directories Confidence: high Scope-risk: moderate Directive: Keep checkpoint rewind as the source of truth for undo semantics; workspace diffs are a preview surface, not the restore authority Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts src/pages/ActiveSession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun /tmp/cc-haha-layout-e2e/run-layout-e2e.mjs Not-tested: Native Tauri packaged app window chrome behavior
This commit is contained in:
parent
b811ffb8f1
commit
3a6f45d173
199
desktop/src/components/chat/CurrentTurnChangeCard.tsx
Normal file
199
desktop/src/components/chat/CurrentTurnChangeCard.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type DiffPreviewState = {
|
||||
loading: boolean
|
||||
diff?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type CurrentTurnChangeCardProps = {
|
||||
sessionId: string
|
||||
preview: SessionRewindResponse
|
||||
workDir: string | null
|
||||
error: string | null
|
||||
isUndoing: boolean
|
||||
onUndo: () => void
|
||||
}
|
||||
|
||||
export function CurrentTurnChangeCard({
|
||||
sessionId,
|
||||
preview,
|
||||
workDir,
|
||||
error,
|
||||
isUndoing,
|
||||
onUndo,
|
||||
}: CurrentTurnChangeCardProps) {
|
||||
const t = useTranslation()
|
||||
const [expandedPath, setExpandedPath] = useState<string | null>(null)
|
||||
const [diffByPath, setDiffByPath] = useState<Record<string, DiffPreviewState>>({})
|
||||
|
||||
const files = useMemo(
|
||||
() => preview.code.filesChanged.map((filePath) => relativizeWorkspacePath(filePath, workDir)),
|
||||
[preview.code.filesChanged, workDir],
|
||||
)
|
||||
|
||||
const toggleDiff = useCallback((filePath: string) => {
|
||||
const nextExpandedPath = expandedPath === filePath ? null : filePath
|
||||
setExpandedPath(nextExpandedPath)
|
||||
if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: { loading: true },
|
||||
}))
|
||||
|
||||
void sessionsApi
|
||||
.getWorkspaceDiff(sessionId, filePath)
|
||||
.then((result) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
loading: false,
|
||||
diff: result.state === 'ok' ? result.diff || '' : undefined,
|
||||
error: result.state === 'ok'
|
||||
? undefined
|
||||
: result.error || t('chat.turnChangesDiffUnavailable'),
|
||||
},
|
||||
}))
|
||||
})
|
||||
.catch((diffError) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
loading: false,
|
||||
error: diffError instanceof Error
|
||||
? diffError.message
|
||||
: String(diffError),
|
||||
},
|
||||
}))
|
||||
})
|
||||
}, [diffByPath, expandedPath, sessionId, t])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mb-5 max-w-[760px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
|
||||
aria-label={t('chat.turnChangesCardLabel')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('chat.turnChangesTitle', { count: files.length })}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
|
||||
+{preview.code.insertions}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
|
||||
-{preview.code.deletions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesSubtitle')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={isUndoing}
|
||||
aria-label={t('chat.turnChangesUndoAria')}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">undo</span>
|
||||
{isUndoing ? t('chat.turnChangesUndoing') : t('chat.turnChangesUndo')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{files.map((filePath) => {
|
||||
const isExpanded = expandedPath === filePath
|
||||
const diffState = diffByPath[filePath]
|
||||
return (
|
||||
<div key={filePath}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDiff(filePath)}
|
||||
aria-label={t(
|
||||
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
|
||||
{ path: filePath },
|
||||
)}
|
||||
className="flex min-h-11 w-full items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
|
||||
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
|
||||
{filePath}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3">
|
||||
{diffState?.loading ? (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesDiffLoading')}
|
||||
</div>
|
||||
) : diffState?.error ? (
|
||||
<div className="text-xs text-[var(--color-error)]">
|
||||
{diffState.error}
|
||||
</div>
|
||||
) : diffState?.diff ? (
|
||||
<DiffPreview diff={diffState.diff} />
|
||||
) : (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesDiffUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffPreview({ diff }: { diff: string }) {
|
||||
const lines = diff.split('\n').slice(0, 220)
|
||||
return (
|
||||
<pre className="max-h-[360px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)] p-3 font-mono text-[12px] leading-5 text-[var(--color-code-fg)]">
|
||||
{lines.map((line, index) => {
|
||||
const colorClass = line.startsWith('+') && !line.startsWith('+++')
|
||||
? 'text-[var(--color-success)]'
|
||||
: line.startsWith('-') && !line.startsWith('---')
|
||||
? 'text-[var(--color-error)]'
|
||||
: line.startsWith('@@')
|
||||
? 'text-[var(--color-brand)]'
|
||||
: 'text-[var(--color-code-fg)]'
|
||||
return (
|
||||
<div key={`${index}-${line}`} className={`${colorClass} whitespace-pre`}>
|
||||
{line || ' '}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath
|
||||
|
||||
const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (normalizedPath === normalizedWorkDir) return ''
|
||||
if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) {
|
||||
return normalizedPath.slice(normalizedWorkDir.length + 1)
|
||||
}
|
||||
return normalizedPath
|
||||
}
|
||||
@ -682,6 +682,227 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a current-turn change card from checkpoint preview', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx', 'src/lib/api.ts'],
|
||||
insertions: 12,
|
||||
deletions: 4,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一段',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'ok',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二段',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('2 files changed')).toBeTruthy()
|
||||
expect(screen.getByText('+12')).toBeTruthy()
|
||||
expect(screen.getByText('-4')).toBeTruthy()
|
||||
expect(screen.getByText('src/App.tsx')).toBeTruthy()
|
||||
expect(screen.getByText('src/lib/api.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二段',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a current-turn changed file diff', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/App.tsx',
|
||||
diff: 'diff --session a/src/App.tsx b/src/App.tsx\n-old\n+new',
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '改一下',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
|
||||
|
||||
expect(await screen.findByText('+new')).toBeTruthy()
|
||||
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
|
||||
})
|
||||
|
||||
it('undoes the current turn from the change card using checkpoint rewind', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['user-1', 'assistant-1'],
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '做一个页面',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
expectedContent: '做一个页面',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '做一个页面',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows raw startup details under translated CLI startup errors', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -17,6 +17,7 @@ import { PermissionDialog } from './PermissionDialog'
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { StreamingIndicator } from './StreamingIndicator'
|
||||
import { InlineTaskSummary } from './InlineTaskSummary'
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Button } from '../shared/Button'
|
||||
@ -34,6 +35,19 @@ type RenderModel = {
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
}
|
||||
|
||||
type RewindTurnTarget = {
|
||||
messageId: string
|
||||
userMessageIndex: number
|
||||
content: string
|
||||
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
|
||||
}
|
||||
|
||||
type CurrentTurnPreview = {
|
||||
target: RewindTurnTarget
|
||||
preview: SessionRewindResponse
|
||||
workDir: string | null
|
||||
}
|
||||
|
||||
function appendChildToolCall(
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
parentToolUseId: string,
|
||||
@ -104,6 +118,41 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
return { renderItems: items, toolResultMap, childToolCallsByParent }
|
||||
}
|
||||
|
||||
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
|
||||
let userMessageIndex = -1
|
||||
let latestTarget: (RewindTurnTarget & { messageOffset: number }) | null = null
|
||||
|
||||
for (let messageOffset = 0; messageOffset < messages.length; messageOffset += 1) {
|
||||
const message = messages[messageOffset]
|
||||
if (!message || message.type !== 'user_text' || message.pending) continue
|
||||
userMessageIndex += 1
|
||||
latestTarget = {
|
||||
messageId: message.id,
|
||||
userMessageIndex,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
messageOffset,
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTarget) return null
|
||||
|
||||
const hasResponseAfterTarget = messages
|
||||
.slice(latestTarget.messageOffset + 1)
|
||||
.some((message) =>
|
||||
message.type === 'assistant_text' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'error' ||
|
||||
message.type === 'task_summary',
|
||||
)
|
||||
|
||||
if (!hasResponseAfterTarget) return null
|
||||
|
||||
const { messageOffset: _messageOffset, ...target } = latestTarget
|
||||
return target
|
||||
}
|
||||
|
||||
type MessageListProps = {
|
||||
sessionId?: string | null
|
||||
compact?: boolean
|
||||
@ -151,6 +200,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const [rewindError, setRewindError] = useState<string | null>(null)
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
|
||||
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
|
||||
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
|
||||
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
|
||||
const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false)
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
@ -216,6 +269,69 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!resolvedSessionId ||
|
||||
!latestTurnTarget ||
|
||||
chatState !== 'idle' ||
|
||||
isMemberSession
|
||||
) {
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoadingCurrentTurnPreview(true)
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
|
||||
Promise.all([
|
||||
sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: latestTurnTarget.messageId,
|
||||
userMessageIndex: latestTurnTarget.userMessageIndex,
|
||||
expectedContent: latestTurnTarget.content,
|
||||
dryRun: true,
|
||||
}),
|
||||
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
|
||||
])
|
||||
.then(([preview, workspaceStatus]) => {
|
||||
if (cancelled) return
|
||||
if (!preview.code.available || preview.code.filesChanged.length === 0) {
|
||||
setCurrentTurnPreview(null)
|
||||
return
|
||||
}
|
||||
setCurrentTurnPreview({
|
||||
target: latestTurnTarget,
|
||||
preview,
|
||||
workDir: workspaceStatus?.workDir ?? null,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
const message =
|
||||
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)
|
||||
setCurrentTurnError(message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
|
||||
|
||||
const closeRewindModal = useCallback(() => {
|
||||
if (isExecutingRewind) return
|
||||
@ -286,6 +402,67 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
t,
|
||||
])
|
||||
|
||||
const handleUndoCurrentTurn = useCallback(async () => {
|
||||
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
|
||||
|
||||
const target = currentTurnPreview.target
|
||||
setIsUndoingCurrentTurn(true)
|
||||
setCurrentTurnError(null)
|
||||
|
||||
try {
|
||||
if (chatState !== 'idle') {
|
||||
stopGeneration(resolvedSessionId)
|
||||
}
|
||||
|
||||
const result = await sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: target.messageId,
|
||||
userMessageIndex: target.userMessageIndex,
|
||||
expectedContent: target.content,
|
||||
})
|
||||
|
||||
await reloadHistory(resolvedSessionId)
|
||||
queueComposerPrefill(resolvedSessionId, {
|
||||
text: target.content,
|
||||
attachments: target.attachments,
|
||||
})
|
||||
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: result.code.available
|
||||
? t('chat.rewindSuccessWithCode', {
|
||||
count: result.conversation.messagesRemoved,
|
||||
})
|
||||
: t('chat.rewindSuccessConversationOnly', {
|
||||
count: result.conversation.messagesRemoved,
|
||||
}),
|
||||
})
|
||||
|
||||
setCurrentTurnPreview(null)
|
||||
} catch (error) {
|
||||
const message =
|
||||
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)
|
||||
setCurrentTurnError(message)
|
||||
} finally {
|
||||
setIsUndoingCurrentTurn(false)
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
chatState,
|
||||
currentTurnPreview,
|
||||
isUndoingCurrentTurn,
|
||||
queueComposerPrefill,
|
||||
reloadHistory,
|
||||
resolvedSessionId,
|
||||
stopGeneration,
|
||||
t,
|
||||
])
|
||||
|
||||
let visibleUserMessageIndex = -1
|
||||
|
||||
return (
|
||||
@ -360,6 +537,25 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && (
|
||||
<CurrentTurnChangeCard
|
||||
sessionId={resolvedSessionId}
|
||||
preview={currentTurnPreview.preview}
|
||||
workDir={currentTurnPreview.workDir}
|
||||
error={currentTurnError}
|
||||
isUndoing={isUndoingCurrentTurn}
|
||||
onUndo={() => {
|
||||
void handleUndoCurrentTurn()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentTurnPreview && currentTurnError && (
|
||||
<div className="mx-auto mb-5 max-w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{currentTurnError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
|
||||
@ -164,7 +164,10 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
const view = await renderPanel('session-changed')
|
||||
|
||||
expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)')
|
||||
const compactPanel = view.getByTestId('workspace-panel')
|
||||
expect(compactPanel.style.width).toBe('520px')
|
||||
expect(compactPanel.style.maxWidth).toBe('36%')
|
||||
expect(compactPanel.style.minWidth).toBe('min(340px, 40%)')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed')
|
||||
@ -209,6 +212,10 @@ describe('WorkspacePanel', () => {
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
|
||||
})
|
||||
const expandedPanel = view.getByTestId('workspace-panel')
|
||||
expect(expandedPanel.style.width).toBe('860px')
|
||||
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
|
||||
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
|
||||
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
|
||||
@ -738,6 +738,11 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const hasPreviewTabs = previewTabs.length > 0
|
||||
const panelWidth = hasPreviewTabs ? width : Math.min(width, 520)
|
||||
const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%'
|
||||
const panelMinWidth = hasPreviewTabs ? 'min(420px, 54%)' : 'min(340px, 40%)'
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadStatus(sessionId)
|
||||
if (activeView === 'all') {
|
||||
@ -975,9 +980,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
<aside
|
||||
data-testid="workspace-panel"
|
||||
className="flex h-full shrink-0 border-l border-[#e7e7e7] bg-white"
|
||||
style={{ width, maxWidth: 'calc(100% - 348px)' }}
|
||||
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
||||
>
|
||||
{previewTabs.length > 0 && (
|
||||
{hasPreviewTabs && (
|
||||
<div className="flex min-w-0 flex-1 flex-col border-r border-[#e7e7e7] bg-white">
|
||||
{renderPreviewTabs()}
|
||||
{renderPreviewContent()}
|
||||
@ -985,7 +990,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`${previewTabs.length > 0 ? 'basis-[38%] min-w-[210px] max-w-[360px]' : 'w-full'} flex h-full shrink-0 flex-col bg-white`}
|
||||
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-white`}
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[#ececec] px-3">
|
||||
<div className="relative min-w-0">
|
||||
|
||||
@ -634,6 +634,16 @@ export const en = {
|
||||
'chat.rewindFilesMore': '+{count} more',
|
||||
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
|
||||
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
|
||||
'chat.turnChangesCardLabel': 'Current turn changed files',
|
||||
'chat.turnChangesTitle': '{count} files changed',
|
||||
'chat.turnChangesSubtitle': 'Current turn checkpoint',
|
||||
'chat.turnChangesUndo': 'Undo',
|
||||
'chat.turnChangesUndoing': 'Undoing...',
|
||||
'chat.turnChangesUndoAria': 'Undo current turn changes',
|
||||
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
|
||||
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
|
||||
'chat.turnChangesDiffLoading': 'Loading diff...',
|
||||
'chat.turnChangesDiffUnavailable': 'Diff unavailable.',
|
||||
|
||||
// ─── Streaming Indicator ──────────────────────────────────────
|
||||
'streaming.thinking': 'Thinking',
|
||||
|
||||
@ -636,6 +636,16 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.rewindFilesMore': '另有 {count} 个',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesCardLabel': '当前轮次已更改文件',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
'chat.turnChangesSubtitle': '当前轮次检查点',
|
||||
'chat.turnChangesUndo': '撤销',
|
||||
'chat.turnChangesUndoing': '正在撤销...',
|
||||
'chat.turnChangesUndoAria': '撤销当前轮次变更',
|
||||
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
|
||||
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
|
||||
'chat.turnChangesDiffLoading': '正在加载 diff...',
|
||||
'chat.turnChangesDiffUnavailable': 'Diff 不可用。',
|
||||
|
||||
// ─── Streaming Indicator ──────────────────────────────────────
|
||||
'streaming.thinking': '思考中',
|
||||
|
||||
@ -249,7 +249,8 @@ describe('ActiveSession task polling', () => {
|
||||
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
|
||||
expect(chatColumn).toHaveClass('shrink-0')
|
||||
expect(chatColumn).toHaveClass('flex-1')
|
||||
expect(chatColumn).not.toHaveClass('shrink-0')
|
||||
expect(contentRow.children[0]).toBe(chatColumn)
|
||||
expect(contentRow.children[1]).toBe(resizeHandle)
|
||||
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))
|
||||
|
||||
@ -15,8 +15,8 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
|
||||
const TASK_POLL_INTERVAL_MS = 1000
|
||||
const WORKSPACE_RESIZE_STEP = 32
|
||||
const CHAT_COLUMN_COMPACT_CLASS =
|
||||
'w-[clamp(340px,23vw,420px)] min-w-[340px] max-w-[420px] shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
|
||||
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
|
||||
function WorkspaceResizeHandle() {
|
||||
const t = useTranslation()
|
||||
@ -165,7 +165,7 @@ export function ActiveSession() {
|
||||
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
data-testid="active-session-chat-column"
|
||||
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_COMPACT_CLASS : 'min-w-[360px] flex-1'}`}
|
||||
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : 'min-w-[360px] flex-1'}`}
|
||||
>
|
||||
{isMemberSession && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
|
||||
@ -1667,6 +1667,83 @@ describe('Sessions API', () => {
|
||||
expect(remainingMessages[0]?.id).toBe(firstUserId)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
|
||||
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'created-on-second-turn')
|
||||
const firstFile = path.join(workDir, 'src', 'step.js')
|
||||
const createdFile = path.join(workDir, 'notes', 'generated.txt')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const backupV1 = 'second-created-step@v1'
|
||||
const backupV2 = 'second-created-step@v2'
|
||||
|
||||
await fs.mkdir(path.dirname(firstFile), { recursive: true })
|
||||
await fs.mkdir(path.dirname(createdFile), { recursive: true })
|
||||
await fs.writeFile(firstFile, "export const STEP = 'v2'\n", 'utf-8')
|
||||
await fs.writeFile(createdFile, 'generated\n', 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n")
|
||||
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n")
|
||||
|
||||
await writeSessionFile('-tmp-api-second-turn-created', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeFileHistorySnapshotEntry(firstUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV1,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v1', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE', firstUserId),
|
||||
makeFileHistorySnapshotEntry(secondUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV2,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
'notes/generated.txt': {
|
||||
backupFileName: null,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v2 and create file', secondUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE', secondUserId),
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[]; insertions: number }
|
||||
}
|
||||
expect(preview.code.filesChanged.sort()).toEqual([
|
||||
createdFile,
|
||||
firstFile,
|
||||
].sort())
|
||||
expect(preview.code.insertions).toBe(2)
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
expect(await fs.readFile(firstFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
|
||||
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversations API via /api/sessions/:id/chat
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -215,6 +215,19 @@ function getBackupFileNameFirstVersion(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getBackupFileNameForTarget(
|
||||
trackingPath: string,
|
||||
snapshots: FileHistorySnapshot[],
|
||||
targetSnapshot: FileHistorySnapshot,
|
||||
): string | null | undefined {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
if (targetBackup && 'backupFileName' in targetBackup) {
|
||||
return targetBackup.backupFileName
|
||||
}
|
||||
|
||||
return getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
}
|
||||
|
||||
async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
@ -223,6 +236,12 @@ async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function countInsertedLines(content: string): number {
|
||||
return diffLines('', content).reduce((total, change) => (
|
||||
change.added ? total + (change.count || 0) : total
|
||||
), 0)
|
||||
}
|
||||
|
||||
async function hasFileChanged(
|
||||
filePath: string,
|
||||
backupFilePath: string,
|
||||
@ -305,21 +324,21 @@ async function buildCodePreview(
|
||||
let deletions = 0
|
||||
|
||||
for (const trackingPath of trackedPaths) {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
const backupFileName =
|
||||
targetBackup?.backupFileName ??
|
||||
getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
const backupFileName = getBackupFileNameForTarget(
|
||||
trackingPath,
|
||||
snapshots,
|
||||
targetSnapshot,
|
||||
)
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
const absolutePath = expandTrackingPath(workDir, trackingPath)
|
||||
|
||||
if (backupFileName === null) {
|
||||
try {
|
||||
await stat(absolutePath)
|
||||
const currentContent = await readFileOrNull(absolutePath)
|
||||
if (currentContent !== null) {
|
||||
filesChanged.push(absolutePath)
|
||||
} catch {
|
||||
// File is already absent.
|
||||
insertions += countInsertedLines(currentContent)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@ -412,10 +431,11 @@ export async function executeSessionRewind(
|
||||
}
|
||||
|
||||
for (const trackingPath of collectTrackedPaths(snapshots)) {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
const backupFileName =
|
||||
targetBackup?.backupFileName ??
|
||||
getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
const backupFileName = getBackupFileNameForTarget(
|
||||
trackingPath,
|
||||
snapshots,
|
||||
targetSnapshot,
|
||||
)
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user