mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep workspace changes and session checkpoints reliable
The desktop workspace panel and chat transcript were mixing project-level file state with per-turn session history, which made rewind, file attachment, and changed-file previews brittle across multi-turn and historical sessions. This keeps turn checkpoints durable in the transcript, makes workspace refreshes happen at the right lifecycle points, and hardens long file previews without blocking the UI. Constraint: Right-side workspace changes are project working-tree state, while chat turn cards are session checkpoint state. Rejected: Treat every changed-file panel entry as session-local | new sessions must still reveal existing dirty project files. Confidence: high Scope-risk: moderate Directive: Do not couple global workspace status to session checkpoint cards without preserving both product meanings. Tested: cd desktop && bun run test src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bun run lint Tested: git diff --check Tested: cd desktop && bun run test Not-tested: Packaged Tauri runtime smoke test
This commit is contained in:
parent
e6378af234
commit
f5cf5932b4
@ -201,6 +201,22 @@ export type WorkspaceDiffResult = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpoint = {
|
||||
target: SessionRewindResponse['target']
|
||||
conversation?: SessionRewindResponse['conversation']
|
||||
code: SessionRewindResponse['code']
|
||||
workDir?: string
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointsResponse = {
|
||||
checkpoints: SessionTurnCheckpoint[]
|
||||
}
|
||||
|
||||
export type TurnCheckpointDiffResult = WorkspaceDiffResult & {
|
||||
target?: SessionRewindResponse['target']
|
||||
workDir?: string
|
||||
}
|
||||
|
||||
function buildWorkspacePath(
|
||||
sessionId: string,
|
||||
resource: 'status' | 'tree' | 'file' | 'diff',
|
||||
@ -279,6 +295,19 @@ export const sessionsApi = {
|
||||
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
|
||||
},
|
||||
|
||||
getTurnCheckpoints(sessionId: string) {
|
||||
return api.get<SessionTurnCheckpointsResponse>(`/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
},
|
||||
|
||||
getTurnCheckpointDiff(sessionId: string, targetUserMessageId: string, workspacePath: string) {
|
||||
const query = new URLSearchParams()
|
||||
query.set('targetUserMessageId', targetUserMessageId)
|
||||
query.set('path', workspacePath)
|
||||
return api.get<TurnCheckpointDiffResult>(
|
||||
`/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
rewind(sessionId: string, body: {
|
||||
targetUserMessageId?: string
|
||||
userMessageIndex?: number
|
||||
|
||||
158
desktop/src/components/chat/ChatInput.test.tsx
Normal file
158
desktop/src/components/chat/ChatInput.test.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getGitInfo: vi.fn(),
|
||||
search: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
wsSend: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getGitInfo: mocks.getGitInfo,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
search: mocks.search,
|
||||
browse: mocks.browse,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
wsManager: {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
onMessage: vi.fn(() => () => {}),
|
||||
clearHandlers: vi.fn(),
|
||||
send: mocks.wsSend,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../controls/PermissionModeSelector', () => ({
|
||||
PermissionModeSelector: () => <button type="button">Permissions</button>,
|
||||
}))
|
||||
|
||||
vi.mock('../controls/ModelSelector', () => ({
|
||||
ModelSelector: () => <button type="button">Model</button>,
|
||||
}))
|
||||
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
|
||||
describe('ChatInput file mentions', () => {
|
||||
const sessionId = 'session-file-mention'
|
||||
const initialChatState = useChatStore.getInitialState()
|
||||
const initialSessionState = useSessionStore.getInitialState()
|
||||
const initialTabState = useTabStore.getInitialState()
|
||||
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useChatStore.setState(initialChatState, true)
|
||||
useSessionStore.setState(initialSessionState, true)
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true)
|
||||
|
||||
useTabStore.setState({
|
||||
activeTabId: sessionId,
|
||||
tabs: [{ sessionId, title: 'Project', type: 'session', status: 'idle' }],
|
||||
})
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Project',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-01T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '/repo',
|
||||
workDir: '/repo',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
||||
})
|
||||
|
||||
it('turns a selected @ file into a chip without corrupting the typed path', async () => {
|
||||
mocks.search.mockResolvedValueOnce({
|
||||
currentPath: '/repo/backend/src',
|
||||
parentPath: '/repo/backend',
|
||||
query: 'conditions.py',
|
||||
entries: [
|
||||
{ name: 'conditions.py', path: '/repo/backend/src/conditions.py', isDirectory: false },
|
||||
],
|
||||
})
|
||||
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
const mention = '@backend/src/conditions.py'
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: `${mention} 记一下这个文件讲了什么东西。`,
|
||||
selectionStart: mention.length,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByText('conditions.py'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input.value).toBe('记一下这个文件讲了什么东西。')
|
||||
})
|
||||
expect(screen.getByText('conditions.py')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: '记一下这个文件讲了什么东西。',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'conditions.py',
|
||||
path: '/repo/backend/src/conditions.py',
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
note: undefined,
|
||||
quote: undefined,
|
||||
}],
|
||||
})
|
||||
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
||||
expect(messages[messages.length - 1]).toMatchObject({
|
||||
type: 'user_text',
|
||||
content: '记一下这个文件讲了什么东西。',
|
||||
modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。',
|
||||
attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -310,7 +310,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
// Extract filter text after @
|
||||
const filter = textBeforeCursor.slice(pos + 1)
|
||||
setAtFilter(filter)
|
||||
setAtCursorPos(cursorPos)
|
||||
setAtCursorPos(pos)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(true)
|
||||
}, [])
|
||||
@ -629,17 +629,34 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
ref={fileSearchRef}
|
||||
cwd={resolvedWorkDir || ''}
|
||||
filter={atFilter}
|
||||
onNavigate={(relativePath) => {
|
||||
if (atCursorPos < 0) return
|
||||
const replacement = `@${relativePath}`
|
||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
||||
const newCursorPos = atCursorPos + replacement.length
|
||||
setInput(newValue)
|
||||
setAtFilter(relativePath)
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||
})
|
||||
}}
|
||||
onSelect={(path, name) => {
|
||||
if (atCursorPos >= 0) {
|
||||
// Insert name at cursor position, replacing filter text
|
||||
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
||||
const newCursorPos = atCursorPos + name.length
|
||||
const referenceName = name.split('/').filter(Boolean).pop() ?? name
|
||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||
const beforeToken = input.slice(0, atCursorPos)
|
||||
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
|
||||
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
|
||||
const newValue = `${beforeToken}${spacer}${afterToken}`
|
||||
const newCursorPos = atCursorPos + spacer.length
|
||||
if (activeTabId) {
|
||||
addWorkspaceReference(activeTabId, {
|
||||
kind: 'file',
|
||||
path,
|
||||
absolutePath: path,
|
||||
name,
|
||||
name: referenceName,
|
||||
})
|
||||
}
|
||||
setInput(newValue)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
|
||||
|
||||
@ -11,48 +11,60 @@ type DiffPreviewState = {
|
||||
|
||||
type CurrentTurnChangeCardProps = {
|
||||
sessionId: string
|
||||
preview: SessionRewindResponse
|
||||
targetUserMessageId: string
|
||||
checkpoint: SessionTurnCheckpoint
|
||||
workDir: string | null
|
||||
error: string | null
|
||||
isUndoing: boolean
|
||||
isLatest: boolean
|
||||
onUndo: () => void
|
||||
}
|
||||
|
||||
type ChangedFileEntry = {
|
||||
apiPath: string
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
export function CurrentTurnChangeCard({
|
||||
sessionId,
|
||||
preview,
|
||||
targetUserMessageId,
|
||||
checkpoint,
|
||||
workDir,
|
||||
error,
|
||||
isUndoing,
|
||||
isLatest,
|
||||
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 files = useMemo<ChangedFileEntry[]>(
|
||||
() => checkpoint.code.filesChanged.map((filePath) => ({
|
||||
apiPath: filePath,
|
||||
displayPath: relativizeWorkspacePath(filePath, workDir),
|
||||
})),
|
||||
[checkpoint.code.filesChanged, workDir],
|
||||
)
|
||||
|
||||
const toggleDiff = useCallback((filePath: string) => {
|
||||
const nextExpandedPath = expandedPath === filePath ? null : filePath
|
||||
const toggleDiff = useCallback((fileEntry: ChangedFileEntry) => {
|
||||
const nextExpandedPath = expandedPath === fileEntry.apiPath ? null : fileEntry.apiPath
|
||||
setExpandedPath(nextExpandedPath)
|
||||
if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) {
|
||||
if (!nextExpandedPath || diffByPath[fileEntry.apiPath]?.diff || diffByPath[fileEntry.apiPath]?.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: { loading: true },
|
||||
[fileEntry.apiPath]: { loading: true },
|
||||
}))
|
||||
|
||||
void sessionsApi
|
||||
.getWorkspaceDiff(sessionId, filePath)
|
||||
.getTurnCheckpointDiff(sessionId, targetUserMessageId, fileEntry.apiPath)
|
||||
.then((result) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
[fileEntry.apiPath]: {
|
||||
loading: false,
|
||||
diff: result.state === 'ok' ? result.diff || '' : undefined,
|
||||
error: result.state === 'ok'
|
||||
@ -64,7 +76,7 @@ export function CurrentTurnChangeCard({
|
||||
.catch((diffError) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
[fileEntry.apiPath]: {
|
||||
loading: false,
|
||||
error: diffError instanceof Error
|
||||
? diffError.message
|
||||
@ -72,12 +84,25 @@ export function CurrentTurnChangeCard({
|
||||
},
|
||||
}))
|
||||
})
|
||||
}, [diffByPath, expandedPath, sessionId, t])
|
||||
}, [diffByPath, expandedPath, sessionId, t, targetUserMessageId])
|
||||
|
||||
const cardLabel = isLatest
|
||||
? t('chat.turnChangesLatestCardLabel')
|
||||
: t('chat.turnChangesHistoricalCardLabel')
|
||||
const subtitle = isLatest
|
||||
? t('chat.turnChangesLatestSubtitle')
|
||||
: t('chat.turnChangesHistoricalSubtitle')
|
||||
const undoLabel = isLatest
|
||||
? t('chat.turnChangesLatestUndo')
|
||||
: t('chat.turnChangesHistoricalUndo')
|
||||
const undoAria = isLatest
|
||||
? t('chat.turnChangesLatestUndoAria')
|
||||
: t('chat.turnChangesHistoricalUndoAria')
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
|
||||
aria-label={t('chat.turnChangesCardLabel')}
|
||||
aria-label={cardLabel}
|
||||
>
|
||||
<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">
|
||||
@ -86,14 +111,14 @@ export function CurrentTurnChangeCard({
|
||||
{t('chat.turnChangesTitle', { count: files.length })}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
|
||||
+{preview.code.insertions}
|
||||
+{checkpoint.code.insertions}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
|
||||
-{preview.code.deletions}
|
||||
-{checkpoint.code.deletions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesSubtitle')}
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -101,26 +126,26 @@ export function CurrentTurnChangeCard({
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={isUndoing}
|
||||
aria-label={t('chat.turnChangesUndoAria')}
|
||||
aria-label={undoAria}
|
||||
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')}
|
||||
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{files.map((filePath) => {
|
||||
const isExpanded = expandedPath === filePath
|
||||
const diffState = diffByPath[filePath]
|
||||
{files.map((fileEntry) => {
|
||||
const isExpanded = expandedPath === fileEntry.apiPath
|
||||
const diffState = diffByPath[fileEntry.apiPath]
|
||||
return (
|
||||
<div key={filePath}>
|
||||
<div key={fileEntry.apiPath}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDiff(filePath)}
|
||||
onClick={() => toggleDiff(fileEntry)}
|
||||
aria-label={t(
|
||||
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
|
||||
{ path: filePath },
|
||||
{ path: fileEntry.displayPath },
|
||||
)}
|
||||
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"
|
||||
>
|
||||
@ -128,7 +153,7 @@ export function CurrentTurnChangeCard({
|
||||
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
|
||||
{filePath}
|
||||
{fileEntry.displayPath}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -145,7 +170,7 @@ export function CurrentTurnChangeCard({
|
||||
) : diffState?.diff ? (
|
||||
<WorkspaceDiffSurface
|
||||
value={diffState.diff}
|
||||
path={filePath}
|
||||
path={fileEntry.displayPath}
|
||||
className="max-h-[430px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { FileSearchMenu } from './FileSearchMenu'
|
||||
import { ApiError } from '../../api/client'
|
||||
@ -55,4 +55,65 @@ describe('FileSearchMenu', () => {
|
||||
expect(screen.getByText('preview.png')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates directories without selecting them as attachments', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const onNavigate = vi.fn()
|
||||
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
|
||||
currentPath: '/repo',
|
||||
parentPath: '/',
|
||||
query: 'backend',
|
||||
entries: [
|
||||
{ name: 'backend', path: '/repo/backend', isDirectory: true },
|
||||
],
|
||||
})
|
||||
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
|
||||
currentPath: '/repo/backend',
|
||||
parentPath: '/repo',
|
||||
entries: [
|
||||
{ name: 'src', path: '/repo/backend/src', isDirectory: true },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<FileSearchMenu
|
||||
cwd="/repo"
|
||||
filter="backend"
|
||||
onSelect={onSelect}
|
||||
onNavigate={onNavigate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByText('backend'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(onNavigate).toHaveBeenCalledWith('backend/')
|
||||
await waitFor(() => {
|
||||
expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('passes nested relative file paths when selecting a file', async () => {
|
||||
const onSelect = vi.fn()
|
||||
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
|
||||
currentPath: '/repo/backend/src',
|
||||
parentPath: '/repo/backend',
|
||||
query: 'pictactic',
|
||||
entries: [
|
||||
{ name: 'pictactic', path: '/repo/backend/src/pictactic', isDirectory: false },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<FileSearchMenu
|
||||
cwd="/repo"
|
||||
filter="backend/src/pictactic"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByText('pictactic'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic')
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,9 +18,14 @@ type Props = {
|
||||
cwd: string
|
||||
filter?: string
|
||||
onSelect: (path: string, relativePath: string) => void
|
||||
onNavigate?: (relativePath: string) => void
|
||||
}
|
||||
|
||||
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect }, ref) => {
|
||||
function joinRelativePath(base: string, name: string) {
|
||||
return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect, onNavigate }, ref) => {
|
||||
const t = useTranslation()
|
||||
const [entries, setEntries] = useState<DirEntry[]>([])
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
@ -118,13 +123,19 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (entries[selectedIndex]) {
|
||||
onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name)
|
||||
const selected = entries[selectedIndex]
|
||||
if (selected) {
|
||||
if (selected.isDirectory) {
|
||||
void loadDir(selected.path, '')
|
||||
onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)}/`)
|
||||
} else {
|
||||
onSelect(selected.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries, selectedIndex])
|
||||
}, [entries, selectedIndex, filter, loadDir, onNavigate, onSelect])
|
||||
|
||||
useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown])
|
||||
|
||||
@ -185,7 +196,8 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
|
||||
key={entry.path}
|
||||
data-index={i}
|
||||
onClick={() => {
|
||||
void loadDir(entry.path, filter)
|
||||
void loadDir(entry.path, '')
|
||||
onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name)}/`)
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
@ -204,7 +216,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
|
||||
<button
|
||||
key={entry.path}
|
||||
data-index={idx}
|
||||
onClick={() => onSelect(entry.path, entry.name)}
|
||||
onClick={() => onSelect(entry.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name))}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
selectedIndex === idx ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
|
||||
@ -39,6 +39,17 @@ describe('MessageList nested tool calls', () => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
)
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
|
||||
@ -564,29 +575,22 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
|
||||
it('does not expose the old message-level rewind action', 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: 4,
|
||||
deletions: 1,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 4,
|
||||
deletions: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
@ -616,30 +620,83 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
|
||||
})
|
||||
|
||||
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,
|
||||
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({} as never)
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '继续优化 workflow.py',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: '两个文件均已优化完成,功能保持不变。',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('两个文件均已优化完成,功能保持不变。')).toBeTruthy()
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.queryByText(/Cannot read properties/)).toBeNull()
|
||||
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders multiple historical turn change cards across three turns', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/first.ts'],
|
||||
insertions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 5,
|
||||
deletions: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-3',
|
||||
userMessageIndex: 2,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
@ -662,7 +719,102 @@ describe('MessageList nested tool calls', () => {
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二段',
|
||||
modelContent: '@"/tmp/example-project/src/App.tsx" 第二段',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 4,
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
type: 'user_text',
|
||||
content: '第三段',
|
||||
timestamp: 5,
|
||||
},
|
||||
{
|
||||
id: 'assistant-3',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const cards = await screen.findAllByLabelText('Turn changed files')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||||
expect(screen.getByText('src/second.ts')).toBeTruthy()
|
||||
expect(screen.queryByText('src/third.ts')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a historical turn diff through the turn checkpoint diff API', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/first.ts'],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 2,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/first.ts',
|
||||
diff: 'diff --session a/src/first.ts b/src/first.ts\n-old\n+new',
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/first.ts',
|
||||
diff: 'diff --session a/src/first.ts b/src/first.ts\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,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二轮',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
@ -678,49 +830,49 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('2 files changed')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Current turn changed files').className).toContain('w-full max-w-[860px]')
|
||||
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: '@"/tmp/example-project/src/App.tsx" 第二段',
|
||||
dryRun: true,
|
||||
})
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
|
||||
|
||||
const diffSurface = await screen.findByTestId('workspace-code')
|
||||
expect(diffSurface.textContent).toContain('+new')
|
||||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||||
ACTIVE_TAB,
|
||||
'user-1',
|
||||
'src/first.ts',
|
||||
)
|
||||
expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
it('keeps checkpoint paths bound to the original turn cwd when expanding historical diffs', async () => {
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
workDir: '/tmp/current-project',
|
||||
repoName: 'current-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
workDir: '/tmp/old-project',
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['/tmp/old-project/src/first.ts'],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/App.tsx',
|
||||
diff: 'diff --session a/src/App.tsx b/src/App.tsx\n-old\n+new',
|
||||
path: '/tmp/old-project/src/first.ts',
|
||||
diff: 'diff --git a/src/first.ts b/src/first.ts\n-old\n+new',
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
@ -730,7 +882,7 @@ describe('MessageList nested tool calls', () => {
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '改一下',
|
||||
content: '第一轮',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
@ -746,14 +898,107 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/first.ts' }))
|
||||
|
||||
const diffSurface = await screen.findByTestId('workspace-code')
|
||||
expect(diffSurface.textContent).toContain('+new')
|
||||
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
|
||||
await screen.findByTestId('workspace-code')
|
||||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||||
ACTIVE_TAB,
|
||||
'user-1',
|
||||
'/tmp/old-project/src/first.ts',
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms before undoing the current turn from the change card', async () => {
|
||||
it('keeps historical turn change cards visible while the next turn is running', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/first.ts'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一轮',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
]
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({ messages }),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('src/first.ts')).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages,
|
||||
chatState: 'thinking',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('confirms before rewinding to an earlier turn from a historical change card', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/first.ts'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
@ -788,14 +1033,6 @@ describe('MessageList nested tool calls', () => {
|
||||
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()
|
||||
|
||||
@ -811,99 +1048,6 @@ describe('MessageList nested tool calls', () => {
|
||||
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' }))
|
||||
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
expect(within(dialog).getByText('This will rewind the latest assistant response and restore tracked files for this turn.')).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
|
||||
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('undoes only the latest completed turn when earlier turns also changed files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['user-2', 'assistant-2'],
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
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',
|
||||
@ -929,49 +1073,68 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('1 files changed')).toBeTruthy()
|
||||
expect(screen.getByText('src/second.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮需求',
|
||||
dryRun: true,
|
||||
})
|
||||
const historicalCard = (await screen.findByText('src/first.ts')).closest('section')
|
||||
expect(historicalCard).toBeTruthy()
|
||||
fireEvent.click(
|
||||
within(historicalCard as HTMLElement).getByRole('button', {
|
||||
name: 'Rewind to before this turn',
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
expect(sessionsApi.rewind).not.toHaveBeenCalled()
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Rewind to before this turn?' })
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.',
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Rewind to before this turn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮需求',
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
expectedContent: '做一个页面',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '第二轮需求',
|
||||
text: '做一个页面',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale current-turn change card when the latest completed turn has no files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
it('does not render cards for turns without file changes', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/first.ts'],
|
||||
insertions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
@ -1009,15 +1172,10 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮只解释',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByLabelText('Current turn changed files')).toBeNull()
|
||||
const cards = await screen.findAllByLabelText('Turn changed files')
|
||||
expect(cards).toHaveLength(1)
|
||||
expect(screen.getByText('src/first.ts')).toBeTruthy()
|
||||
expect(screen.queryByText('src/second.ts')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows raw startup details under translated CLI startup errors', () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
|
||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
@ -42,10 +42,11 @@ type RewindTurnTarget = {
|
||||
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
|
||||
}
|
||||
|
||||
type CurrentTurnPreview = {
|
||||
type TurnChangeCardModel = {
|
||||
target: RewindTurnTarget
|
||||
preview: SessionRewindResponse
|
||||
checkpoint: SessionTurnCheckpoint
|
||||
workDir: string | null
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
function appendChildToolCall(
|
||||
@ -118,40 +119,117 @@ 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
|
||||
function isTurnResponseMessage(message: UIMessage) {
|
||||
return (
|
||||
message.type === 'assistant_text' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'error' ||
|
||||
message.type === 'task_summary'
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
expectedContent: message.modelContent ?? message.content,
|
||||
attachments: message.attachments,
|
||||
messageOffset,
|
||||
export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] {
|
||||
let userMessageIndex = -1
|
||||
const completedTurns: RewindTurnTarget[] = []
|
||||
let currentTarget: RewindTurnTarget | null = null
|
||||
let hasResponseForCurrentTarget = false
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === 'user_text' && !message.pending) {
|
||||
if (currentTarget && hasResponseForCurrentTarget) {
|
||||
completedTurns.push(currentTarget)
|
||||
}
|
||||
userMessageIndex += 1
|
||||
currentTarget = {
|
||||
messageId: message.id,
|
||||
userMessageIndex,
|
||||
content: message.content,
|
||||
expectedContent: message.modelContent ?? message.content,
|
||||
attachments: message.attachments,
|
||||
}
|
||||
hasResponseForCurrentTarget = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentTarget && isTurnResponseMessage(message)) {
|
||||
hasResponseForCurrentTarget = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTarget) return null
|
||||
if (currentTarget && hasResponseForCurrentTarget) {
|
||||
completedTurns.push(currentTarget)
|
||||
}
|
||||
|
||||
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',
|
||||
)
|
||||
return completedTurns
|
||||
}
|
||||
|
||||
if (!hasResponseAfterTarget) return null
|
||||
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
|
||||
const completedTurns = getCompletedTurnTargets(messages)
|
||||
return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null
|
||||
}
|
||||
|
||||
const { messageOffset: _messageOffset, ...target } = latestTarget
|
||||
return target
|
||||
function buildTurnCardInsertionMap(
|
||||
renderItems: RenderItem[],
|
||||
turnChangeCards: TurnChangeCardModel[],
|
||||
) {
|
||||
const lastResponseIndexByTurnId = new Map<string, number>()
|
||||
let activeTurnId: string | null = null
|
||||
|
||||
renderItems.forEach((item, index) => {
|
||||
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
|
||||
activeTurnId = item.message.id
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTurnId) {
|
||||
lastResponseIndexByTurnId.set(activeTurnId, index)
|
||||
}
|
||||
})
|
||||
|
||||
const cardsByRenderIndex = new Map<number, TurnChangeCardModel[]>()
|
||||
turnChangeCards.forEach((card) => {
|
||||
const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId)
|
||||
if (renderIndex === undefined) return
|
||||
const existing = cardsByRenderIndex.get(renderIndex)
|
||||
if (existing) {
|
||||
existing.push(card)
|
||||
} else {
|
||||
cardsByRenderIndex.set(renderIndex, [card])
|
||||
}
|
||||
})
|
||||
|
||||
return cardsByRenderIndex
|
||||
}
|
||||
|
||||
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 isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const checkpoint = value as Partial<SessionTurnCheckpoint>
|
||||
return (
|
||||
Boolean(checkpoint.target) &&
|
||||
typeof checkpoint.target?.targetUserMessageId === 'string' &&
|
||||
typeof checkpoint.target?.userMessageIndex === 'number' &&
|
||||
Boolean(checkpoint.code) &&
|
||||
typeof checkpoint.code?.available === 'boolean' &&
|
||||
Array.isArray(checkpoint.code?.filesChanged)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] {
|
||||
if (!response || typeof response !== 'object') return []
|
||||
const checkpoints = (response as { checkpoints?: unknown }).checkpoints
|
||||
if (!Array.isArray(checkpoints)) return []
|
||||
return checkpoints.filter(isSessionTurnCheckpoint)
|
||||
}
|
||||
|
||||
type MessageListProps = {
|
||||
@ -191,11 +269,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const t = useTranslation()
|
||||
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 [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false)
|
||||
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
|
||||
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
|
||||
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
|
||||
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
|
||||
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
|
||||
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
@ -218,76 +297,90 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
|
||||
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
|
||||
const latestCompletedTurnId =
|
||||
completedTurnTargets.length > 0
|
||||
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
|
||||
: null
|
||||
const turnCardsByRenderIndex = useMemo(
|
||||
() => buildTurnCardInsertionMap(renderItems, turnChangeCards),
|
||||
[renderItems, turnChangeCards],
|
||||
)
|
||||
const confirmTurnCard = useMemo(
|
||||
() => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
|
||||
[turnChangeCards, turnUndoConfirmTargetId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!resolvedSessionId ||
|
||||
!latestTurnTarget ||
|
||||
chatState !== 'idle' ||
|
||||
isMemberSession
|
||||
) {
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) {
|
||||
setTurnChangeCards([])
|
||||
setTurnChangeLoadError(null)
|
||||
setIsLoadingTurnChangeCards(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (chatState !== 'idle') {
|
||||
setTurnChangeLoadError(null)
|
||||
setIsLoadingTurnChangeCards(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoadingCurrentTurnPreview(true)
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
setIsLoadingTurnChangeCards(true)
|
||||
setTurnChangeLoadError(null)
|
||||
|
||||
Promise.all([
|
||||
sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: latestTurnTarget.messageId,
|
||||
userMessageIndex: latestTurnTarget.userMessageIndex,
|
||||
expectedContent: latestTurnTarget.expectedContent,
|
||||
dryRun: true,
|
||||
}),
|
||||
sessionsApi.getTurnCheckpoints(resolvedSessionId),
|
||||
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
|
||||
])
|
||||
.then(([preview, workspaceStatus]) => {
|
||||
.then(([checkpointResponse, workspaceStatus]) => {
|
||||
if (cancelled) return
|
||||
if (!preview.code.available || preview.code.filesChanged.length === 0) {
|
||||
setCurrentTurnPreview(null)
|
||||
return
|
||||
}
|
||||
setCurrentTurnPreview({
|
||||
target: latestTurnTarget,
|
||||
preview,
|
||||
workDir: workspaceStatus?.workDir ?? null,
|
||||
})
|
||||
const targetByMessageId = new Map(
|
||||
completedTurnTargets.map((target) => [target.messageId, target] as const),
|
||||
)
|
||||
|
||||
setTurnChangeCards(
|
||||
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
|
||||
const target = targetByMessageId.get(checkpoint.target.targetUserMessageId)
|
||||
if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
target,
|
||||
checkpoint,
|
||||
workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null,
|
||||
isLatest: target.messageId === latestCompletedTurnId,
|
||||
}]
|
||||
}),
|
||||
)
|
||||
})
|
||||
.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)
|
||||
setTurnChangeCards([])
|
||||
setTurnChangeLoadError(getApiErrorMessage(error))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
setIsLoadingTurnChangeCards(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
|
||||
}, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId])
|
||||
|
||||
const handleUndoCurrentTurn = useCallback(async () => {
|
||||
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
|
||||
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return
|
||||
|
||||
const target = currentTurnPreview.target
|
||||
setIsUndoingCurrentTurn(true)
|
||||
setCurrentTurnError(null)
|
||||
const target = confirmTurnCard.target
|
||||
setRewindingTurnId(target.messageId)
|
||||
setTurnActionErrors((current) => {
|
||||
if (!(target.messageId in current)) return current
|
||||
const next = { ...current }
|
||||
delete next[target.messageId]
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
if (chatState !== 'idle') {
|
||||
@ -317,30 +410,24 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}),
|
||||
})
|
||||
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
setTurnUndoConfirmTargetId(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)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
setTurnActionErrors((current) => ({
|
||||
...current,
|
||||
[target.messageId]: getApiErrorMessage(error),
|
||||
}))
|
||||
setTurnUndoConfirmTargetId(null)
|
||||
} finally {
|
||||
setIsUndoingCurrentTurn(false)
|
||||
setRewindingTurnId(null)
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
chatState,
|
||||
currentTurnPreview,
|
||||
isUndoingCurrentTurn,
|
||||
confirmTurnCard,
|
||||
queueComposerPrefill,
|
||||
reloadHistory,
|
||||
resolvedSessionId,
|
||||
rewindingTurnId,
|
||||
stopGeneration,
|
||||
t,
|
||||
])
|
||||
@ -352,39 +439,54 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'tool_group') {
|
||||
return (
|
||||
<ToolCallGroup
|
||||
key={item.id}
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{renderItems.map((item, index) => {
|
||||
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
||||
|
||||
const msg = item.message
|
||||
return (
|
||||
<MessageBlock
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
msg.type === 'tool_use'
|
||||
? (() => {
|
||||
const r = toolResultMap.get(msg.toolUseId)
|
||||
return r ? { content: r.content, isError: r.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
resultMap={toolResultMap}
|
||||
childToolCallsByParent={childToolCallsByParent}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
isStreaming={
|
||||
chatState === 'tool_executing' &&
|
||||
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MessageBlock
|
||||
message={item.message}
|
||||
activeThinkingId={activeThinkingId}
|
||||
agentTaskNotifications={agentTaskNotifications}
|
||||
toolResult={
|
||||
item.message.type === 'tool_use'
|
||||
? (() => {
|
||||
const result = toolResultMap.get(item.message.toolUseId)
|
||||
return result ? { content: result.content, isError: result.isError } : null
|
||||
})()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.target.messageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -400,22 +502,9 @@ 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={() => {
|
||||
setCurrentTurnUndoConfirmOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentTurnPreview && currentTurnError && (
|
||||
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
|
||||
<div className="mx-auto mb-5 w-full max-w-[860px] 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}
|
||||
{turnChangeLoadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -423,19 +512,25 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={currentTurnUndoConfirmOpen}
|
||||
open={Boolean(confirmTurnCard)}
|
||||
onClose={() => {
|
||||
if (!isUndoingCurrentTurn) {
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
if (!rewindingTurnId) {
|
||||
setTurnUndoConfirmTargetId(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={handleUndoCurrentTurn}
|
||||
title={t('chat.turnChangesConfirmTitle')}
|
||||
body={t('chat.turnChangesConfirmBody')}
|
||||
confirmLabel={t('chat.turnChangesConfirmUndo')}
|
||||
title={confirmTurnCard?.isLatest
|
||||
? t('chat.turnChangesLatestConfirmTitle')
|
||||
: t('chat.turnChangesHistoricalConfirmTitle')}
|
||||
body={confirmTurnCard?.isLatest
|
||||
? t('chat.turnChangesLatestConfirmBody')
|
||||
: t('chat.turnChangesHistoricalConfirmBody')}
|
||||
confirmLabel={confirmTurnCard?.isLatest
|
||||
? t('chat.turnChangesLatestConfirmUndo')
|
||||
: t('chat.turnChangesHistoricalConfirmUndo')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isUndoingCurrentTurn}
|
||||
loading={Boolean(rewindingTurnId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 420
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
|
||||
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
|
||||
|
||||
export const workspacePrismTheme: PrismTheme = {
|
||||
plain: {
|
||||
@ -95,10 +97,15 @@ export function WorkspaceDiffSurface({
|
||||
lineLimit?: number
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = lines.slice(0, lineLimit)
|
||||
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
|
||||
const language = getLanguageFromPath(path)
|
||||
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllLines(false)
|
||||
}, [path, value])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -119,7 +126,7 @@ export function WorkspaceDiffSurface({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${index}:${line}`}
|
||||
key={index}
|
||||
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
|
||||
isAdded
|
||||
? 'bg-[var(--color-diff-added-bg)]'
|
||||
@ -153,7 +160,7 @@ export function WorkspaceDiffSurface({
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isCodeLine ? (
|
||||
{isCodeLine && !usePlainLargePreview ? (
|
||||
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
|
||||
) : (
|
||||
code || ' '
|
||||
@ -163,9 +170,20 @@ export function WorkspaceDiffSurface({
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: lineLimit })}
|
||||
{lines.length > lineLimit && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
<span>
|
||||
{showAllLines
|
||||
? t('workspace.previewAllLines', { total: lines.length })
|
||||
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllLines((current) => !current)}
|
||||
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -120,6 +120,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
}))
|
||||
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { WorkspacePanel } from './WorkspacePanel'
|
||||
@ -128,17 +129,38 @@ describe('WorkspacePanel', () => {
|
||||
const workspaceInitialState = useWorkspacePanelStore.getInitialState()
|
||||
const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState()
|
||||
const settingsInitialState = useSettingsStore.getInitialState()
|
||||
const chatInitialState = useChatStore.getInitialState()
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await setWorkspaceState(workspaceInitialState)
|
||||
useChatStore.setState(chatInitialState, true)
|
||||
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'en' })
|
||||
|
||||
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) =>
|
||||
useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
)
|
||||
getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') =>
|
||||
useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? {
|
||||
state: 'ok',
|
||||
path,
|
||||
entries: [],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await setWorkspaceState(workspaceInitialState)
|
||||
useChatStore.setState(chatInitialState, true)
|
||||
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
|
||||
await setSettingsState(settingsInitialState)
|
||||
vi.restoreAllMocks()
|
||||
@ -234,6 +256,101 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
|
||||
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/Fresh.ts',
|
||||
status: 'modified',
|
||||
additions: 4,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
getMocks().getWorkspaceTreeMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [{ name: 'src', path: 'src', isDirectory: true }],
|
||||
})
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-stale-all': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-stale-all': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-stale-all')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
||||
})
|
||||
expect(view.getByText('src/Fresh.ts')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('loads workspace status when opened while the chat is running', async () => {
|
||||
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/running.ts',
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-running-open': {
|
||||
chatState: 'thinking',
|
||||
} as never,
|
||||
},
|
||||
})
|
||||
|
||||
await act(() => {
|
||||
useWorkspacePanelStore.getState().openPanel('session-running-open')
|
||||
})
|
||||
|
||||
const view = await renderPanel('session-running-open')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(view.getByText('src/running.ts')).toBeTruthy()
|
||||
})
|
||||
expect(view.queryByText('Loading...')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders transcript-derived changed files for non-git sessions', async () => {
|
||||
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
@ -459,6 +576,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-tabs': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -534,6 +652,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-dark-theme': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -579,8 +698,8 @@ describe('WorkspacePanel', () => {
|
||||
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
|
||||
})
|
||||
|
||||
it('caps rendered preview lines to keep large diffs responsive', async () => {
|
||||
const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n')
|
||||
it('can expand long diff previews beyond the default rendered line cap', async () => {
|
||||
const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n')
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
@ -589,6 +708,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-large-preview': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -623,9 +743,59 @@ describe('WorkspacePanel', () => {
|
||||
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
||||
|
||||
expect(highlightedCode).toContain('+line 1')
|
||||
expect(highlightedCode).toContain('+line 420')
|
||||
expect(highlightedCode).not.toContain('+line 421')
|
||||
expect(view.getByText('Showing first 420 lines. Open in your editor for the full file.')).toBeTruthy()
|
||||
expect(highlightedCode).toContain('+line 2000')
|
||||
expect(highlightedCode).not.toContain('+line 2001')
|
||||
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
|
||||
})
|
||||
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('can expand long file previews beyond the default rendered line cap', async () => {
|
||||
const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n')
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-large-file-preview': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-large-file-preview': [{
|
||||
id: 'file:large-file.ts',
|
||||
path: 'large-file.ts',
|
||||
kind: 'file',
|
||||
title: 'large-file.ts',
|
||||
content: longFile,
|
||||
language: 'typescript',
|
||||
previewType: 'text',
|
||||
state: 'ok',
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-large-file-preview': 'file:large-file.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-large-file-preview')
|
||||
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
||||
|
||||
expect(highlightedCode).toContain('const line1 = 1')
|
||||
expect(highlightedCode).toContain('const line2000 = 2000')
|
||||
expect(highlightedCode).not.toContain('const line2001 = 2001')
|
||||
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
|
||||
})
|
||||
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders image previews from workspace files', async () => {
|
||||
@ -724,6 +894,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-preview-menu': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -922,6 +1093,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-zh': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -950,6 +1122,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-compact-header': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -987,6 +1160,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-empty': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
@ -1006,6 +1180,20 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
expect(view.getByText('No changes')).toBeTruthy()
|
||||
|
||||
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === 'session-error') {
|
||||
throw new Error('status failed')
|
||||
}
|
||||
return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
}
|
||||
})
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
@ -1013,6 +1201,7 @@ describe('WorkspacePanel', () => {
|
||||
'session-error': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
@ -1028,6 +1217,8 @@ describe('WorkspacePanel', () => {
|
||||
view.rerender(<WorkspacePanel sessionId="session-error" />)
|
||||
})
|
||||
|
||||
expect(view.getByText('status failed')).toBeTruthy()
|
||||
await waitFor(() => {
|
||||
expect(view.getByText('status failed')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||
import { Highlight } from 'prism-react-renderer'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
@ -14,11 +14,13 @@ import {
|
||||
type WorkspacePreviewKind,
|
||||
type WorkspacePreviewTab,
|
||||
} from '../../stores/workspacePanelStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import {
|
||||
getFileExtension,
|
||||
normalizePrismLanguage,
|
||||
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
|
||||
WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
WorkspaceDiffSurface,
|
||||
workspacePrismTheme,
|
||||
@ -315,11 +317,18 @@ function CodeSurface({
|
||||
const t = useTranslation()
|
||||
const [commentLine, setCommentLine] = useState<number | null>(null)
|
||||
const [commentDraft, setCommentDraft] = useState('')
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const visibleCode = visibleLines.join('\n')
|
||||
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
|
||||
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
|
||||
const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n')
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllLines(false)
|
||||
setCommentLine(null)
|
||||
setCommentDraft('')
|
||||
}, [language, value])
|
||||
|
||||
const submitLineComment = () => {
|
||||
if (!commentLine || !commentDraft.trim()) return
|
||||
@ -328,99 +337,142 @@ function CodeSurface({
|
||||
setCommentDraft('')
|
||||
}
|
||||
|
||||
const renderLineCommentEditor = (lineNumber: number) => {
|
||||
if (commentLine !== lineNumber) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
|
||||
<span aria-hidden="true" />
|
||||
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
|
||||
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.commentLineTarget', { line: lineNumber })}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
autoFocus
|
||||
rows={3}
|
||||
placeholder={t('workspace.commentPlaceholder')}
|
||||
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCommentLine(null)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLineComment}
|
||||
disabled={!commentDraft.trim()}
|
||||
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{t('workspace.addCommentToChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderLineNumberButton = (lineNumber: number) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.commentLine', { line: lineNumber })}
|
||||
onClick={() => {
|
||||
setCommentLine(lineNumber)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
|
||||
>
|
||||
{lineNumber}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]">
|
||||
<div className="relative min-w-max py-2">
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={visibleCode}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
|
||||
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
|
||||
>
|
||||
{tokens.map((line, index) => {
|
||||
const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
|
||||
const lineNumber = index + 1
|
||||
return (
|
||||
<div key={String(lineKey)}>
|
||||
<div
|
||||
{...lineProps}
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.commentLine', { line: lineNumber })}
|
||||
onClick={() => {
|
||||
setCommentLine(lineNumber)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
|
||||
>
|
||||
{lineNumber}
|
||||
</button>
|
||||
<span className="whitespace-pre pr-6">
|
||||
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{commentLine === lineNumber && (
|
||||
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
|
||||
<span aria-hidden="true" />
|
||||
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
|
||||
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.commentLineTarget', { line: lineNumber })}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
autoFocus
|
||||
rows={3}
|
||||
placeholder={t('workspace.commentPlaceholder')}
|
||||
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCommentLine(null)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLineComment}
|
||||
disabled={!commentDraft.trim()}
|
||||
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{t('workspace.addCommentToChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{usePlainLargePreview ? (
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
|
||||
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
|
||||
>
|
||||
{visibleLines.map((line, index) => {
|
||||
const lineNumber = index + 1
|
||||
return (
|
||||
<div key={lineNumber}>
|
||||
<div className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]">
|
||||
{renderLineNumberButton(lineNumber)}
|
||||
<span className="whitespace-pre pr-6">{line || ' '}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: WORKSPACE_PREVIEW_LINE_LIMIT })}
|
||||
{renderLineCommentEditor(lineNumber)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
) : (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={visibleCode}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
|
||||
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
|
||||
>
|
||||
{tokens.map((line, index) => {
|
||||
const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
|
||||
const lineNumber = index + 1
|
||||
return (
|
||||
<div key={String(lineKey)}>
|
||||
<div
|
||||
{...lineProps}
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{renderLineNumberButton(lineNumber)}
|
||||
<span className="whitespace-pre pr-6">
|
||||
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{renderLineCommentEditor(lineNumber)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)}
|
||||
{lines.length > WORKSPACE_PREVIEW_LINE_LIMIT && (
|
||||
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
<span>
|
||||
{showAllLines
|
||||
? t('workspace.previewAllLines', { total: lines.length })
|
||||
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllLines((current) => !current)}
|
||||
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -651,6 +703,12 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
|
||||
const refreshLifecycleRef = useRef({
|
||||
sessionId,
|
||||
isOpen: false,
|
||||
chatState: 'idle',
|
||||
})
|
||||
|
||||
const rootTree = treeByPath['']
|
||||
const rootTreeKey = makeTreeStateKey(sessionId, '')
|
||||
@ -682,9 +740,22 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeView !== 'changed' || status || statusLoading || statusError) return
|
||||
const previous = refreshLifecycleRef.current
|
||||
const sessionChanged = previous.sessionId !== sessionId
|
||||
const opened = isOpen && (sessionChanged || !previous.isOpen)
|
||||
const completedTurn =
|
||||
isOpen &&
|
||||
!sessionChanged &&
|
||||
previous.chatState !== 'idle' &&
|
||||
chatState === 'idle'
|
||||
|
||||
refreshLifecycleRef.current = { sessionId, isOpen, chatState }
|
||||
|
||||
const shouldRefreshOnOpen = opened
|
||||
const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle'
|
||||
if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return
|
||||
void loadStatus(sessionId)
|
||||
}, [activeView, isOpen, loadStatus, sessionId, status, statusError, statusLoading])
|
||||
}, [chatState, isOpen, loadStatus, sessionId, statusLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
|
||||
|
||||
@ -67,7 +67,10 @@ export const en = {
|
||||
'workspace.previewState.tooLarge': 'File is too large to preview.',
|
||||
'workspace.previewState.missing': 'File not found.',
|
||||
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
|
||||
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
|
||||
'workspace.previewLineLimit': 'Showing first {count} of {total} loaded lines.',
|
||||
'workspace.previewAllLines': 'Showing all {total} loaded lines.',
|
||||
'workspace.showAllLoadedLines': 'Show all loaded lines',
|
||||
'workspace.collapsePreview': 'Collapse preview',
|
||||
'workspace.addToChat': 'Add to chat',
|
||||
'workspace.copyPath': 'Copy path',
|
||||
'workspace.localComment': 'Local comment',
|
||||
@ -648,15 +651,22 @@ export const en = {
|
||||
'chat.stopTitle': 'Stop generation (Cmd+.)',
|
||||
'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.turnChangesLatestCardLabel': 'Turn changed files',
|
||||
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
|
||||
'chat.turnChangesLatestSubtitle': 'Current turn checkpoint',
|
||||
'chat.turnChangesHistoricalSubtitle': 'Saved turn checkpoint',
|
||||
'chat.turnChangesLatestUndo': 'Undo current turn',
|
||||
'chat.turnChangesHistoricalUndo': 'Rewind to before this turn',
|
||||
'chat.turnChangesUndoing': 'Undoing...',
|
||||
'chat.turnChangesUndoAria': 'Undo current turn changes',
|
||||
'chat.turnChangesConfirmTitle': 'Undo current turn?',
|
||||
'chat.turnChangesConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
|
||||
'chat.turnChangesConfirmUndo': 'Undo current turn',
|
||||
'chat.turnChangesLatestUndoAria': 'Undo current turn changes',
|
||||
'chat.turnChangesHistoricalUndoAria': 'Rewind to before this turn',
|
||||
'chat.turnChangesLatestConfirmTitle': 'Undo current turn?',
|
||||
'chat.turnChangesHistoricalConfirmTitle': 'Rewind to before this turn?',
|
||||
'chat.turnChangesLatestConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
|
||||
'chat.turnChangesHistoricalConfirmBody': 'This will rewind the conversation to before this turn and restore tracked files for that checkpoint.',
|
||||
'chat.turnChangesLatestConfirmUndo': 'Undo current turn',
|
||||
'chat.turnChangesHistoricalConfirmUndo': 'Rewind to before this turn',
|
||||
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
|
||||
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
|
||||
'chat.turnChangesDiffLoading': 'Loading diff...',
|
||||
|
||||
@ -69,7 +69,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.tooLarge': '文件过大,无法预览。',
|
||||
'workspace.previewState.missing': '文件不存在。',
|
||||
'workspace.imagePreviewUnavailable': '图片无法预览。',
|
||||
'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。',
|
||||
'workspace.previewLineLimit': '正在显示已加载内容的前 {count} / {total} 行。',
|
||||
'workspace.previewAllLines': '正在显示全部 {total} 行已加载内容。',
|
||||
'workspace.showAllLoadedLines': '显示全部已加载行',
|
||||
'workspace.collapsePreview': '收起预览',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.localComment': '本地评论',
|
||||
@ -650,15 +653,22 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.stopTitle': '停止生成 (Cmd+.)',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesCardLabel': '当前轮次已更改文件',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
'chat.turnChangesSubtitle': '当前轮次检查点',
|
||||
'chat.turnChangesUndo': '撤销',
|
||||
'chat.turnChangesLatestCardLabel': '轮次已更改文件',
|
||||
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
|
||||
'chat.turnChangesLatestSubtitle': '当前轮次检查点',
|
||||
'chat.turnChangesHistoricalSubtitle': '历史轮次检查点',
|
||||
'chat.turnChangesLatestUndo': '撤销当前轮次',
|
||||
'chat.turnChangesHistoricalUndo': '回滚到这一轮之前',
|
||||
'chat.turnChangesUndoing': '正在撤销...',
|
||||
'chat.turnChangesUndoAria': '撤销当前轮次变更',
|
||||
'chat.turnChangesConfirmTitle': '撤销当前轮次?',
|
||||
'chat.turnChangesConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
|
||||
'chat.turnChangesConfirmUndo': '撤销当前轮次',
|
||||
'chat.turnChangesLatestUndoAria': '撤销当前轮次变更',
|
||||
'chat.turnChangesHistoricalUndoAria': '回滚到这一轮之前',
|
||||
'chat.turnChangesLatestConfirmTitle': '撤销当前轮次?',
|
||||
'chat.turnChangesHistoricalConfirmTitle': '回滚到这一轮之前?',
|
||||
'chat.turnChangesLatestConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
|
||||
'chat.turnChangesHistoricalConfirmBody': '这会把会话回滚到这一轮之前,并恢复该检查点对应的文件变更。',
|
||||
'chat.turnChangesLatestConfirmUndo': '撤销当前轮次',
|
||||
'chat.turnChangesHistoricalConfirmUndo': '回滚到这一轮之前',
|
||||
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
|
||||
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
|
||||
'chat.turnChangesDiffLoading': '正在加载 diff...',
|
||||
|
||||
@ -30,6 +30,7 @@ type Attachment = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'image' | 'file'
|
||||
path?: string
|
||||
mimeType?: string
|
||||
previewUrl?: string
|
||||
data?: string
|
||||
@ -288,6 +289,7 @@ export function EmptySession() {
|
||||
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
path: attachment.path,
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
@ -336,7 +338,7 @@ export function EmptySession() {
|
||||
setAtCursorPos(-1)
|
||||
} else {
|
||||
setAtFilter(textBeforeCursor.slice(pos + 1))
|
||||
setAtCursorPos(cursorPos)
|
||||
setAtCursorPos(pos)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(true)
|
||||
}
|
||||
@ -530,10 +532,37 @@ export function EmptySession() {
|
||||
ref={fileSearchRef}
|
||||
cwd={workDir || ''}
|
||||
filter={atFilter}
|
||||
onSelect={(_path, name) => {
|
||||
onNavigate={(relativePath) => {
|
||||
if (atCursorPos < 0) return
|
||||
const replacement = `@${relativePath}`
|
||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
||||
const newCursorPos = atCursorPos + replacement.length
|
||||
setInput(newValue)
|
||||
setAtFilter(relativePath)
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||
})
|
||||
}}
|
||||
onSelect={(path, name) => {
|
||||
if (atCursorPos >= 0) {
|
||||
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
||||
const newCursorPos = atCursorPos + name.length
|
||||
const attachmentName = name.split('/').filter(Boolean).pop() ?? name
|
||||
const tokenEnd = atCursorPos + 1 + atFilter.length
|
||||
const beforeToken = input.slice(0, atCursorPos)
|
||||
const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '')
|
||||
const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : ''
|
||||
const newValue = `${beforeToken}${spacer}${afterToken}`
|
||||
const newCursorPos = atCursorPos + spacer.length
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: attachmentName,
|
||||
type: 'file',
|
||||
path,
|
||||
},
|
||||
])
|
||||
setInput(newValue)
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
|
||||
@ -312,7 +312,7 @@ describe('chatStore history mapping', () => {
|
||||
{
|
||||
type: 'user_text',
|
||||
content: '改这里',
|
||||
modelContent: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
modelContent: '@"/repo/src/App.tsx" Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
@ -342,6 +342,54 @@ describe('chatStore history mapping', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('stores server-materialized attachment prefixes for rewind matching', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().sendMessage(
|
||||
TEST_SESSION_ID,
|
||||
'记一下这个文件讲了什么东西。',
|
||||
[{ type: 'file', name: 'conditions.py', path: '/repo/backend/conditions.py' }],
|
||||
{
|
||||
displayContent: '记一下这个文件讲了什么东西。',
|
||||
displayAttachments: [{ type: 'file', name: 'conditions.py', path: 'backend/conditions.py' }],
|
||||
},
|
||||
)
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'user_text',
|
||||
content: '记一下这个文件讲了什么东西。',
|
||||
modelContent: '@"/repo/backend/conditions.py" 记一下这个文件讲了什么东西。',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'conditions.py',
|
||||
path: 'backend/conditions.py',
|
||||
}],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps parent tool linkage for live tool events', () => {
|
||||
// Initialize the session first
|
||||
useChatStore.setState({
|
||||
|
||||
@ -264,6 +264,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
sendMessage: (sessionId, content, attachments, options) => {
|
||||
const userFacingContent =
|
||||
options?.displayContent?.trim() || content.trim()
|
||||
const modelFacingContent = buildModelContent(content, attachments)
|
||||
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||
const visibleAttachments = options?.displayAttachments ?? attachments
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
@ -315,7 +316,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
id: nextId(),
|
||||
type: 'user_text',
|
||||
content: userFacingContent,
|
||||
...(userFacingContent !== content ? { modelContent: content } : {}),
|
||||
...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}),
|
||||
attachments: isMemberSession ? undefined : uiAttachments,
|
||||
timestamp: Date.now(),
|
||||
...(isMemberSession ? { pending: true } : {}),
|
||||
@ -925,6 +926,16 @@ type HistoryMappingOptions = {
|
||||
includeTeammateMessages?: boolean
|
||||
}
|
||||
|
||||
function buildModelContent(content: string, attachments?: AttachmentRef[]): string {
|
||||
const paths = attachments
|
||||
?.map((attachment) => attachment.path)
|
||||
.filter((path): path is string => typeof path === 'string' && path.length > 0) ?? []
|
||||
const trimmed = content.trim()
|
||||
if (paths.length === 0) return trimmed
|
||||
const prefix = paths.map((path) => `@"${path}"`).join(' ')
|
||||
return `${prefix} ${trimmed || 'Please analyze the attached files.'}`.trim()
|
||||
}
|
||||
|
||||
function getReferenceName(referencePath: string): string {
|
||||
const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const name = normalized.split('/').filter(Boolean).pop()
|
||||
|
||||
@ -162,6 +162,65 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
|
||||
})
|
||||
|
||||
it('returns to changed-files when a refreshed default all-files view now has changes', async () => {
|
||||
mocks.getWorkspaceStatusMock
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-refresh-changes')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-refresh-changes')
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-refresh-changes')).toBe('all')
|
||||
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-refresh-changes')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-refresh-changes')).toBe('changed')
|
||||
})
|
||||
|
||||
it('does not override an explicit all-files selection when refreshed status has changes', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-explicit-all')
|
||||
useWorkspacePanelStore.getState().setActiveView('session-explicit-all', 'all')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-explicit-all')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-all')).toBe('all')
|
||||
})
|
||||
|
||||
it('does not override an explicit changed-files selection when status is empty', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
|
||||
@ -278,18 +278,17 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
|
||||
set((state) => {
|
||||
const panel = getSessionPanelState(state.panelBySession, sessionId)
|
||||
const shouldDefaultToAllFiles =
|
||||
!panel.hasUserSelectedView
|
||||
&& panel.activeView === 'changed'
|
||||
&& result.state === 'ok'
|
||||
&& result.changedFiles.length === 0
|
||||
const nextActiveView =
|
||||
!panel.hasUserSelectedView && result.state === 'ok'
|
||||
? result.changedFiles.length > 0 ? 'changed' : 'all'
|
||||
: panel.activeView
|
||||
|
||||
return {
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...panel,
|
||||
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
|
||||
activeView: nextActiveView,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
|
||||
@ -221,6 +221,96 @@ async function writeFileHistoryBackup(
|
||||
await fs.writeFile(path.join(dir, backupFileName), content, 'utf-8')
|
||||
}
|
||||
|
||||
type ThreeTurnCheckpointFixture = {
|
||||
sessionId: string
|
||||
workDir: string
|
||||
stepFile: string
|
||||
createdFile: string
|
||||
firstUserId: string
|
||||
secondUserId: string
|
||||
thirdUserId: string
|
||||
}
|
||||
|
||||
async function createThreeTurnCheckpointFixture(
|
||||
sessionId: string,
|
||||
): Promise<ThreeTurnCheckpointFixture> {
|
||||
const workDir = path.join(tmpDir, `turn-checkpoints-${sessionId}`)
|
||||
const stepFile = path.join(workDir, 'src', 'step.js')
|
||||
const createdFile = path.join(workDir, 'notes', 'generated.txt')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const thirdUserId = crypto.randomUUID()
|
||||
const backupBase = `${sessionId}-step@v1`
|
||||
const backupV1 = `${sessionId}-step@v2`
|
||||
const backupV2 = `${sessionId}-step@v3`
|
||||
|
||||
await fs.mkdir(path.dirname(stepFile), { recursive: true })
|
||||
await fs.mkdir(path.dirname(createdFile), { recursive: true })
|
||||
await fs.writeFile(stepFile, "export const STEP = 'v3'\n", 'utf-8')
|
||||
await fs.writeFile(createdFile, 'generated third turn\n', 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, backupBase, "export const STEP = 'base'\n")
|
||||
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'v1'\n")
|
||||
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v2'\n")
|
||||
|
||||
await writeSessionFile('-tmp-api-turn-checkpoints', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeFileHistorySnapshotEntry(firstUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupBase,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v1', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE v1', firstUserId),
|
||||
makeFileHistorySnapshotEntry(secondUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV1,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v2', secondUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE v2', secondUserId),
|
||||
makeFileHistorySnapshotEntry(thirdUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV2,
|
||||
version: 3,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
'notes/generated.txt': {
|
||||
backupFileName: null,
|
||||
version: 3,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v3 and create file', thirdUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE v3', thirdUserId),
|
||||
])
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
workDir,
|
||||
stepFile,
|
||||
createdFile,
|
||||
firstUserId,
|
||||
secondUserId,
|
||||
thirdUserId,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SessionService tests
|
||||
// ============================================================================
|
||||
@ -1883,6 +1973,172 @@ describe('Sessions API', () => {
|
||||
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints should list completed turn previews with turn-bound diff stats', async () => {
|
||||
const fixture = await createThreeTurnCheckpointFixture(
|
||||
'99999999-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
)
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = await res.json() as {
|
||||
checkpoints: Array<{
|
||||
target: {
|
||||
targetUserMessageId: string
|
||||
userMessageIndex: number
|
||||
userMessageCount: number
|
||||
}
|
||||
conversation: { messagesRemoved: number }
|
||||
code: {
|
||||
available: boolean
|
||||
filesChanged: string[]
|
||||
insertions: number
|
||||
deletions: number
|
||||
}
|
||||
workDir: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(body.checkpoints).toHaveLength(3)
|
||||
expect(body.checkpoints).toEqual([
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: fixture.firstUserId,
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
conversation: { messagesRemoved: 6 },
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [fixture.stepFile],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
workDir: fixture.workDir,
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: fixture.secondUserId,
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
conversation: { messagesRemoved: 4 },
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [fixture.stepFile],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
workDir: fixture.workDir,
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: fixture.thirdUserId,
|
||||
userMessageIndex: 2,
|
||||
userMessageCount: 3,
|
||||
},
|
||||
conversation: { messagesRemoved: 2 },
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [fixture.stepFile, fixture.createdFile],
|
||||
insertions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
workDir: fixture.workDir,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints/diff should return target-bound checkpoint diffs', async () => {
|
||||
const fixture = await createThreeTurnCheckpointFixture(
|
||||
'99999999-bbbb-cccc-dddd-ffffffffffff',
|
||||
)
|
||||
|
||||
const secondTurnRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.secondUserId}&path=src/step.js`,
|
||||
)
|
||||
expect(secondTurnRes.status).toBe(200)
|
||||
const secondTurnBody = await secondTurnRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
diff?: string
|
||||
target: { targetUserMessageId: string }
|
||||
}
|
||||
expect(secondTurnBody.target.targetUserMessageId).toBe(fixture.secondUserId)
|
||||
expect(secondTurnBody.state).toBe('ok')
|
||||
expect(secondTurnBody.path).toBe('src/step.js')
|
||||
expect(secondTurnBody.diff).toContain("export const STEP = 'v2'")
|
||||
expect(secondTurnBody.diff).toContain("export const STEP = 'v1'")
|
||||
expect(secondTurnBody.diff).not.toContain("export const STEP = 'v3'")
|
||||
|
||||
const thirdTurnRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=src/step.js`,
|
||||
)
|
||||
expect(thirdTurnRes.status).toBe(200)
|
||||
const thirdTurnBody = await thirdTurnRes.json() as {
|
||||
state: string
|
||||
diff?: string
|
||||
target: { targetUserMessageId: string }
|
||||
}
|
||||
expect(thirdTurnBody.target.targetUserMessageId).toBe(fixture.thirdUserId)
|
||||
expect(thirdTurnBody.state).toBe('ok')
|
||||
expect(thirdTurnBody.diff).toContain("export const STEP = 'v3'")
|
||||
expect(thirdTurnBody.diff).toContain("export const STEP = 'v2'")
|
||||
expect(thirdTurnBody.diff).not.toContain("export const STEP = 'v1'")
|
||||
|
||||
const createdFileRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${fixture.sessionId}/turn-checkpoints/diff?targetUserMessageId=${fixture.thirdUserId}&path=notes/generated.txt`,
|
||||
)
|
||||
expect(createdFileRes.status).toBe(200)
|
||||
const createdFileBody = await createdFileRes.json() as {
|
||||
state: string
|
||||
diff?: string
|
||||
}
|
||||
expect(createdFileBody.state).toBe('ok')
|
||||
expect(createdFileBody.diff).toContain('generated third turn')
|
||||
expect(createdFileBody.diff).toContain('/dev/null')
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should restore the base state when rewinding the first turn of a three-turn file history', async () => {
|
||||
const fixture = await createThreeTurnCheckpointFixture(
|
||||
'aaaaaaaa-1111-2222-3333-444444444444',
|
||||
)
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 0 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
|
||||
expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'base'\n")
|
||||
await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
const remainingMessages = await service.getSessionMessages(fixture.sessionId)
|
||||
expect(remainingMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should keep the first turn and remove later file changes when rewinding the second turn of a three-turn history', async () => {
|
||||
const fixture = await createThreeTurnCheckpointFixture(
|
||||
'aaaaaaaa-5555-6666-7777-888888888888',
|
||||
)
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${fixture.sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
|
||||
expect(await fs.readFile(fixture.stepFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
|
||||
await expect(fs.stat(fixture.createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
const remainingMessages = await service.getSessionMessages(fixture.sessionId)
|
||||
expect(remainingMessages).toHaveLength(2)
|
||||
expect(remainingMessages[0]?.id).toBe(fixture.firstUserId)
|
||||
expect(remainingMessages[1]?.type).toBe('assistant')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversations API via /api/sessions/:id/chat
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
* GET /api/sessions — 列出会话
|
||||
* GET /api/sessions/:id — 获取会话详情
|
||||
* GET /api/sessions/:id/messages — 获取会话消息
|
||||
* GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览
|
||||
* GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff
|
||||
* POST /api/sessions — 创建新会话
|
||||
* DELETE /api/sessions/:id — 删除会话
|
||||
* PATCH /api/sessions/:id — 重命名会话
|
||||
@ -21,6 +23,8 @@ import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
import {
|
||||
executeSessionRewind,
|
||||
getSessionTurnCheckpointDiff,
|
||||
listSessionTurnCheckpoints,
|
||||
previewSessionRewind,
|
||||
type RewindTargetSelector,
|
||||
} from '../services/sessionRewindService.js'
|
||||
@ -95,6 +99,18 @@ export async function handleSessionsApi(
|
||||
return await rewindSession(req, sessionId)
|
||||
}
|
||||
|
||||
if (subResource === 'turn-checkpoints') {
|
||||
if (req.method !== 'GET') {
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return segments[4] === 'diff'
|
||||
? await getTurnCheckpointDiff(sessionId, url)
|
||||
: await getTurnCheckpoints(sessionId)
|
||||
}
|
||||
|
||||
if (subResource === 'slash-commands') {
|
||||
if (req.method !== 'GET') {
|
||||
return Response.json(
|
||||
@ -533,6 +549,41 @@ async function rewindSession(req: Request, sessionId: string): Promise<Response>
|
||||
return Response.json(result)
|
||||
}
|
||||
|
||||
async function getTurnCheckpoints(sessionId: string): Promise<Response> {
|
||||
const checkpoints = await listSessionTurnCheckpoints(sessionId)
|
||||
return Response.json({ checkpoints })
|
||||
}
|
||||
|
||||
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
|
||||
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
|
||||
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
|
||||
const path = url.searchParams.get('path')
|
||||
const userMessageIndex =
|
||||
userMessageIndexParam === null ? undefined : Number.parseInt(userMessageIndexParam, 10)
|
||||
|
||||
if (
|
||||
(typeof targetUserMessageId !== 'string' || targetUserMessageId.length === 0) &&
|
||||
!Number.isInteger(userMessageIndex)
|
||||
) {
|
||||
throw ApiError.badRequest('targetUserMessageId (string) or userMessageIndex (integer) is required')
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw ApiError.badRequest('path query parameter is required for turn checkpoint diff')
|
||||
}
|
||||
|
||||
const result = await getSessionTurnCheckpointDiff(
|
||||
sessionId,
|
||||
{
|
||||
targetUserMessageId,
|
||||
userMessageIndex,
|
||||
},
|
||||
path,
|
||||
)
|
||||
|
||||
return Response.json(result)
|
||||
}
|
||||
|
||||
async function patchSession(req: Request, sessionId: string): Promise<Response> {
|
||||
let body: { title?: string }
|
||||
try {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { diffLines } from 'diff'
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path'
|
||||
import { createTwoFilesPatch, diffLines } from 'diff'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import {
|
||||
type FileHistorySnapshot,
|
||||
@ -49,6 +49,19 @@ export type SessionRewindExecuteResult = SessionRewindPreview & {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointPreview = SessionRewindPreview & {
|
||||
workDir: string
|
||||
}
|
||||
|
||||
export type SessionTurnCheckpointDiffResult = {
|
||||
target: SessionRewindPreview['target']
|
||||
workDir: string
|
||||
path: string
|
||||
state: 'ok' | 'missing' | 'error'
|
||||
diff?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
function normalizeDiffStats(diffStats: {
|
||||
filesChanged?: string[]
|
||||
insertions?: number
|
||||
@ -201,13 +214,13 @@ function findTargetSnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
function getBackupFileNameFirstVersion(
|
||||
function getEarliestBackupFileName(
|
||||
trackingPath: string,
|
||||
snapshots: FileHistorySnapshot[],
|
||||
): string | null | undefined {
|
||||
for (const snapshot of snapshots) {
|
||||
const backup = snapshot.trackedFileBackups[trackingPath]
|
||||
if (backup !== undefined && backup.version === 1) {
|
||||
if (backup !== undefined) {
|
||||
return backup.backupFileName
|
||||
}
|
||||
}
|
||||
@ -225,7 +238,85 @@ function getBackupFileNameForTarget(
|
||||
return targetBackup.backupFileName
|
||||
}
|
||||
|
||||
return getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
return getEarliestBackupFileName(trackingPath, snapshots)
|
||||
}
|
||||
|
||||
async function resolveSessionWorkDir(sessionId: string): Promise<string> {
|
||||
return (
|
||||
(conversationService.hasSession(sessionId)
|
||||
? conversationService.getSessionWorkDir(sessionId)
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveCheckpointBaseDir(
|
||||
sessionId: string,
|
||||
targetUserMessageId: string,
|
||||
fallbackWorkDir?: string,
|
||||
): Promise<string> {
|
||||
return (
|
||||
(await sessionService.getSessionMessageCwd(sessionId, targetUserMessageId)) ||
|
||||
fallbackWorkDir ||
|
||||
(await resolveSessionWorkDir(sessionId))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeComparablePath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function toCheckpointResponsePath(
|
||||
trackingPath: string,
|
||||
checkpointBaseDir: string,
|
||||
): string {
|
||||
if (isAbsolute(trackingPath)) {
|
||||
return trackingPath
|
||||
}
|
||||
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
const relativePath = normalizeComparablePath(relative(checkpointBaseDir, absolutePath))
|
||||
return relativePath && !relativePath.startsWith('../')
|
||||
? relativePath
|
||||
: normalizeComparablePath(trackingPath)
|
||||
}
|
||||
|
||||
function matchesCheckpointPath(
|
||||
requestedPath: string,
|
||||
trackingPath: string,
|
||||
checkpointBaseDir: string,
|
||||
): boolean {
|
||||
const normalizedRequestedPath = normalizeComparablePath(requestedPath)
|
||||
const absolutePath = normalizeComparablePath(
|
||||
expandTrackingPath(checkpointBaseDir, trackingPath),
|
||||
)
|
||||
const responsePath = normalizeComparablePath(
|
||||
toCheckpointResponsePath(trackingPath, checkpointBaseDir),
|
||||
)
|
||||
|
||||
return normalizedRequestedPath === absolutePath ||
|
||||
normalizedRequestedPath === normalizeComparablePath(trackingPath) ||
|
||||
normalizedRequestedPath === responsePath
|
||||
}
|
||||
|
||||
function buildTurnPreview(
|
||||
target: RewindTarget,
|
||||
preview: RewindCodePreview,
|
||||
workDir: string,
|
||||
): SessionTurnCheckpointPreview {
|
||||
return {
|
||||
target: {
|
||||
targetUserMessageId: target.targetUserMessageId,
|
||||
userMessageIndex: target.userMessageIndex,
|
||||
userMessageCount: target.userMessageCount,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: target.messagesRemoved,
|
||||
},
|
||||
code: preview,
|
||||
workDir,
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
@ -242,6 +333,146 @@ function countInsertedLines(content: string): number {
|
||||
), 0)
|
||||
}
|
||||
|
||||
function buildCheckpointDiff(
|
||||
displayPath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
oldExists: boolean,
|
||||
newExists: boolean,
|
||||
): string {
|
||||
const oldFileName = oldExists ? `a/${displayPath}` : '/dev/null'
|
||||
const newFileName = newExists ? `b/${displayPath}` : '/dev/null'
|
||||
|
||||
return createTwoFilesPatch(
|
||||
oldFileName,
|
||||
newFileName,
|
||||
oldContent,
|
||||
newContent,
|
||||
'',
|
||||
'',
|
||||
{ context: 3 },
|
||||
)
|
||||
}
|
||||
|
||||
async function readBackupContent(
|
||||
sessionId: string,
|
||||
backupFileName: string | null | undefined,
|
||||
): Promise<string | null | undefined> {
|
||||
if (backupFileName === undefined) return undefined
|
||||
if (backupFileName === null) return null
|
||||
return await readFileOrNull(resolveBackupPath(sessionId, backupFileName))
|
||||
}
|
||||
|
||||
function countTurnDiffStats(
|
||||
beforeContent: string | null,
|
||||
afterContent: string | null,
|
||||
): { insertions: number; deletions: number } {
|
||||
let insertions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(beforeContent ?? '', afterContent ?? '')) {
|
||||
if (change.added) insertions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
return { insertions, deletions }
|
||||
}
|
||||
|
||||
function getTurnMessageRange(
|
||||
activeMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
|
||||
targetUserMessageId: string,
|
||||
): { start: number; end: number } | null {
|
||||
const start = activeMessages.findIndex((message) => message.id === targetUserMessageId)
|
||||
if (start < 0) return null
|
||||
const nextUserIndex = activeMessages.findIndex(
|
||||
(message, index) => index > start && message.type === 'user',
|
||||
)
|
||||
return { start, end: nextUserIndex >= 0 ? nextUserIndex : activeMessages.length }
|
||||
}
|
||||
|
||||
function hasCompletedTurn(
|
||||
activeMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
|
||||
targetUserMessageId: string,
|
||||
): boolean {
|
||||
const range = getTurnMessageRange(activeMessages, targetUserMessageId)
|
||||
if (!range) return false
|
||||
return activeMessages.slice(range.start + 1, range.end).some((message) =>
|
||||
message.type === 'assistant' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'error',
|
||||
)
|
||||
}
|
||||
|
||||
function getNextUserMessageId(
|
||||
userMessages: Awaited<ReturnType<typeof sessionService.getSessionMessages>>,
|
||||
userMessageIndex: number,
|
||||
): string | null {
|
||||
return userMessages[userMessageIndex + 1]?.id ?? null
|
||||
}
|
||||
|
||||
async function getTurnBoundaryContents(
|
||||
sessionId: string,
|
||||
checkpointBaseDir: string,
|
||||
trackingPath: string,
|
||||
targetSnapshot: FileHistorySnapshot,
|
||||
nextSnapshot: FileHistorySnapshot | null,
|
||||
): Promise<{ beforeContent: string | null; afterContent: string | null }> {
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
const beforeContent = await readBackupContent(
|
||||
sessionId,
|
||||
targetSnapshot.trackedFileBackups[trackingPath]?.backupFileName,
|
||||
)
|
||||
|
||||
if (!nextSnapshot) {
|
||||
return {
|
||||
beforeContent: beforeContent ?? null,
|
||||
afterContent: await readFileOrNull(absolutePath),
|
||||
}
|
||||
}
|
||||
|
||||
const nextContent = await readBackupContent(
|
||||
sessionId,
|
||||
nextSnapshot.trackedFileBackups[trackingPath]?.backupFileName,
|
||||
)
|
||||
|
||||
return {
|
||||
beforeContent: beforeContent ?? null,
|
||||
afterContent: nextContent === undefined ? beforeContent ?? null : nextContent,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildTurnCodePreview(
|
||||
sessionId: string,
|
||||
checkpointBaseDir: string,
|
||||
targetSnapshot: FileHistorySnapshot,
|
||||
nextSnapshot: FileHistorySnapshot | null,
|
||||
): Promise<RewindCodePreview> {
|
||||
const trackedPaths = new Set([
|
||||
...Object.keys(targetSnapshot.trackedFileBackups),
|
||||
...Object.keys(nextSnapshot?.trackedFileBackups ?? {}),
|
||||
])
|
||||
const filesChanged: string[] = []
|
||||
let insertions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const trackingPath of trackedPaths) {
|
||||
const { beforeContent, afterContent } = await getTurnBoundaryContents(
|
||||
sessionId,
|
||||
checkpointBaseDir,
|
||||
trackingPath,
|
||||
targetSnapshot,
|
||||
nextSnapshot,
|
||||
)
|
||||
if (beforeContent === afterContent) continue
|
||||
|
||||
filesChanged.push(expandTrackingPath(checkpointBaseDir, trackingPath))
|
||||
const stats = countTurnDiffStats(beforeContent, afterContent)
|
||||
insertions += stats.insertions
|
||||
deletions += stats.deletions
|
||||
}
|
||||
|
||||
return normalizeDiffStats({ filesChanged, insertions, deletions })
|
||||
}
|
||||
|
||||
async function hasFileChanged(
|
||||
filePath: string,
|
||||
backupFilePath: string,
|
||||
@ -378,15 +609,12 @@ export async function previewSessionRewind(
|
||||
selector: RewindTargetSelector,
|
||||
): Promise<SessionRewindPreview> {
|
||||
const target = await resolveRewindTarget(sessionId, selector)
|
||||
const workDir =
|
||||
(conversationService.hasSession(sessionId)
|
||||
? conversationService.getSessionWorkDir(sessionId)
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const workDir = await resolveSessionWorkDir(sessionId)
|
||||
const checkpointBaseDir = await resolveCheckpointBaseDir(
|
||||
sessionId,
|
||||
target.targetUserMessageId,
|
||||
workDir,
|
||||
)
|
||||
const { preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
checkpointBaseDir,
|
||||
@ -406,20 +634,167 @@ export async function previewSessionRewind(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessionTurnCheckpoints(
|
||||
sessionId: string,
|
||||
): Promise<SessionTurnCheckpointPreview[]> {
|
||||
const activeMessages = await sessionService.getSessionMessages(sessionId)
|
||||
const userMessages = activeMessages.filter((message) => message.type === 'user')
|
||||
if (userMessages.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const workDir = await resolveSessionWorkDir(sessionId)
|
||||
const snapshots = await loadFileHistorySnapshots(sessionId)
|
||||
const checkpoints: SessionTurnCheckpointPreview[] = []
|
||||
|
||||
for (const [userMessageIndex, userMessage] of userMessages.entries()) {
|
||||
const activeMessageIndex = activeMessages.findIndex(
|
||||
(message) => message.id === userMessage.id,
|
||||
)
|
||||
if (activeMessageIndex < 0) continue
|
||||
if (!hasCompletedTurn(activeMessages, userMessage.id)) continue
|
||||
|
||||
const target: RewindTarget = {
|
||||
targetUserMessageId: userMessage.id,
|
||||
userMessageIndex,
|
||||
userMessageCount: userMessages.length,
|
||||
messagesRemoved: activeMessages.length - activeMessageIndex,
|
||||
}
|
||||
const checkpointBaseDir = await resolveCheckpointBaseDir(
|
||||
sessionId,
|
||||
target.targetUserMessageId,
|
||||
workDir,
|
||||
)
|
||||
const targetSnapshot = snapshots ? findTargetSnapshot(snapshots, target.targetUserMessageId) : null
|
||||
const nextUserMessageId = getNextUserMessageId(userMessages, userMessageIndex)
|
||||
const nextSnapshot = nextUserMessageId && snapshots
|
||||
? findTargetSnapshot(snapshots, nextUserMessageId)
|
||||
: null
|
||||
const preview = targetSnapshot
|
||||
? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot)
|
||||
: {
|
||||
available: false,
|
||||
reason: 'No file checkpoint is available for the selected message.',
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
|
||||
if (!preview.available || preview.filesChanged.length === 0) continue
|
||||
checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir))
|
||||
}
|
||||
|
||||
return checkpoints
|
||||
}
|
||||
|
||||
export async function getSessionTurnCheckpointDiff(
|
||||
sessionId: string,
|
||||
selector: RewindTargetSelector,
|
||||
requestedPath: string,
|
||||
): Promise<SessionTurnCheckpointDiffResult> {
|
||||
const target = await resolveRewindTarget(sessionId, selector)
|
||||
const workDir = await resolveSessionWorkDir(sessionId)
|
||||
const checkpointBaseDir = await resolveCheckpointBaseDir(
|
||||
sessionId,
|
||||
target.targetUserMessageId,
|
||||
workDir,
|
||||
)
|
||||
const snapshots = await loadFileHistorySnapshots(sessionId)
|
||||
const missingResult = {
|
||||
target: buildTurnPreview(
|
||||
target,
|
||||
{
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
checkpointBaseDir,
|
||||
).target,
|
||||
workDir: checkpointBaseDir,
|
||||
path: normalizeComparablePath(requestedPath),
|
||||
state: 'missing' as const,
|
||||
}
|
||||
|
||||
if (!snapshots) {
|
||||
return missingResult
|
||||
}
|
||||
|
||||
const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId)
|
||||
if (!targetSnapshot) {
|
||||
return missingResult
|
||||
}
|
||||
const userMessages = (await sessionService.getSessionMessages(sessionId))
|
||||
.filter((message) => message.type === 'user')
|
||||
const nextUserMessageId = getNextUserMessageId(userMessages, target.userMessageIndex)
|
||||
const nextSnapshot = nextUserMessageId
|
||||
? findTargetSnapshot(snapshots, nextUserMessageId)
|
||||
: null
|
||||
|
||||
for (const trackingPath of new Set([
|
||||
...Object.keys(targetSnapshot.trackedFileBackups),
|
||||
...Object.keys(nextSnapshot?.trackedFileBackups ?? {}),
|
||||
])) {
|
||||
if (!matchesCheckpointPath(requestedPath, trackingPath, checkpointBaseDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayPath = toCheckpointResponsePath(trackingPath, checkpointBaseDir)
|
||||
|
||||
try {
|
||||
const { beforeContent, afterContent } = await getTurnBoundaryContents(
|
||||
sessionId,
|
||||
checkpointBaseDir,
|
||||
trackingPath,
|
||||
targetSnapshot,
|
||||
nextSnapshot,
|
||||
)
|
||||
|
||||
if (beforeContent === afterContent) {
|
||||
return {
|
||||
...missingResult,
|
||||
path: displayPath,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
target: missingResult.target,
|
||||
workDir: checkpointBaseDir,
|
||||
path: displayPath,
|
||||
state: 'ok',
|
||||
diff: buildCheckpointDiff(
|
||||
displayPath,
|
||||
beforeContent ?? '',
|
||||
afterContent ?? '',
|
||||
beforeContent !== null,
|
||||
afterContent !== null,
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
target: missingResult.target,
|
||||
workDir: checkpointBaseDir,
|
||||
path: displayPath,
|
||||
state: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missingResult
|
||||
}
|
||||
|
||||
export async function executeSessionRewind(
|
||||
sessionId: string,
|
||||
selector: RewindTargetSelector,
|
||||
): Promise<SessionRewindExecuteResult> {
|
||||
const target = await resolveRewindTarget(sessionId, selector)
|
||||
const workDir =
|
||||
(conversationService.hasSession(sessionId)
|
||||
? conversationService.getSessionWorkDir(sessionId)
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const workDir = await resolveSessionWorkDir(sessionId)
|
||||
const checkpointBaseDir = await resolveCheckpointBaseDir(
|
||||
sessionId,
|
||||
target.targetUserMessageId,
|
||||
workDir,
|
||||
)
|
||||
const { snapshots, preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
checkpointBaseDir,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user