diff --git a/mateclaw-ui/src/views/AgentContext.vue b/mateclaw-ui/src/views/AgentContext.vue index b87205bf..9f90f828 100644 --- a/mateclaw-ui/src/views/AgentContext.vue +++ b/mateclaw-ui/src/views/AgentContext.vue @@ -51,7 +51,7 @@ - + @@ -385,6 +385,7 @@ const originalContent = ref('') const saving = ref(false) // 'off' = editor only, 'split' = side-by-side, 'preview' = preview only const previewMode = ref<'off' | 'split' | 'preview'>('off') +let fileLoadRequestId = 0 // 新建文件 const showNewFileDialog = ref(false) @@ -479,6 +480,7 @@ onMounted(async () => { watch(selectedAgentId, () => { if (selectedAgentId.value) { + fileLoadRequestId += 1 selectedFile.value = null fileContent.value = '' originalContent.value = '' @@ -511,6 +513,32 @@ async function fetchFiles() { } } +async function refreshFilesAndSelection() { + await fetchFiles() + if (!selectedFile.value) return + + if (isPersonalSelected.value) { + await fetchPersonalFiles() + const latestPersonal = personalFiles.value.find(file => + file.filename === selectedFile.value?.filename + && file.ownerKey === selectedFile.value?.ownerKey) + if (latestPersonal) { + await onPersonalFileClick(latestPersonal) + } + return + } + + const latest = files.value.find(file => file.filename === selectedFile.value?.filename) + if (latest) { + await onFileClick(latest) + } else { + fileLoadRequestId += 1 + selectedFile.value = null + fileContent.value = '' + originalContent.value = '' + } +} + async function fetchPersonalFiles() { if (!selectedAgentId.value) return try { @@ -556,9 +584,17 @@ function handlePreviewClick(e: MouseEvent) { } async function onFileClick(file: WorkspaceFile) { + const requestId = ++fileLoadRequestId + const agentId = selectedAgentId.value selectedFile.value = file try { - const res: any = await agentContextApi.getFile(selectedAgentId.value, file.filename) + const res: any = await agentContextApi.getFile(agentId, file.filename) + if ( + requestId !== fileLoadRequestId + || selectedAgentId.value !== agentId + || selectedFile.value?.filename !== file.filename + || isPersonalSelected.value + ) return const data = res.data fileContent.value = data?.content || '' originalContent.value = fileContent.value @@ -568,12 +604,21 @@ async function onFileClick(file: WorkspaceFile) { } async function onPersonalFileClick(file: WorkspaceFile) { + const requestId = ++fileLoadRequestId + const agentId = selectedAgentId.value selectedFile.value = file // Read-only view — markdown preview is the most useful default previewMode.value = 'preview' try { const res: any = await agentContextApi.getPersonalFile( - selectedAgentId.value, file.filename, file.ownerKey || '') + agentId, file.filename, file.ownerKey || '') + if ( + requestId !== fileLoadRequestId + || selectedAgentId.value !== agentId + || selectedFile.value?.filename !== file.filename + || selectedFile.value?.ownerKey !== file.ownerKey + || !isPersonalSelected.value + ) return fileContent.value = res.data?.content || '' originalContent.value = fileContent.value } catch { @@ -631,6 +676,7 @@ async function confirmDeleteFile() { try { await agentContextApi.deleteFile(selectedAgentId.value, name) mcToast.success(t('agentContext.deleteSuccess')) + fileLoadRequestId += 1 selectedFile.value = null fileContent.value = '' originalContent.value = '' diff --git a/mateclaw-ui/src/views/__tests__/AgentContext.test.ts b/mateclaw-ui/src/views/__tests__/AgentContext.test.ts new file mode 100644 index 00000000..315b7b78 --- /dev/null +++ b/mateclaw-ui/src/views/__tests__/AgentContext.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment happy-dom +import { createApp, defineComponent } from 'vue' +import { createI18n } from 'vue-i18n' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AgentContext from '../AgentContext.vue' +import { agentApi, agentContextApi } from '@/api/index' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), +})) + +vi.mock('@/api/index', () => ({ + agentApi: { + list: vi.fn(), + }, + agentContextApi: { + listFiles: vi.fn(), + getFile: vi.fn(), + saveFile: vi.fn(), + deleteFile: vi.fn(), + listPersonalFiles: vi.fn(), + getPersonalFile: vi.fn(), + getPromptFiles: vi.fn(), + setPromptFiles: vi.fn(), + exportMemorySnapshot: vi.fn(), + previewImportMemorySnapshot: vi.fn(), + applyImportMemorySnapshot: vi.fn(), + }, +})) + +vi.mock('@/composables/useMcToast', () => ({ + mcToast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})) + +vi.mock('@/components/common/useConfirm', () => ({ + mcConfirm: vi.fn(), +})) + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: vi.fn(), +})) + +vi.mock('@/composables/useMermaidRenderer', () => ({ + handleMermaidDownload: vi.fn(() => false), +})) + +vi.mock('@/composables/useMarkdownRenderer', () => ({ + useMarkdownRenderer: () => ({ + renderMarkdown: (value: string) => `${value}`, + }), +})) + +vi.mock('@/components/common/AgentPickerDialog.vue', () => ({ + default: defineComponent({ + name: 'AgentPickerDialogStub', + props: { + modelValue: [String, Number], + agents: Array, + }, + emits: ['update:modelValue'], + template: '', + }), +})) + +const apps: Array> = [] + +function mountAgentContext() { + const host = document.createElement('div') + document.body.appendChild(host) + const app = createApp(AgentContext) + app.use(createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} } })) + app.mount(host) + apps.push(app) + return host +} + +async function eventually(assertion: () => void) { + let lastError: unknown + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) + try { + assertion() + return + } catch (error) { + lastError = error + } + } + throw lastError +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(next => { resolve = next }) + return { promise, resolve } +} + +describe('AgentContext previews', () => { + beforeEach(() => { + vi.mocked(agentApi.list).mockResolvedValue({ + data: [{ id: 'agent-1', name: '会议管理' }], + } as never) + vi.mocked(agentContextApi.getPromptFiles).mockResolvedValue({ data: ['AGENTS.md'] } as never) + vi.mocked(agentContextApi.listPersonalFiles).mockResolvedValue({ data: [] } as never) + }) + + afterEach(() => { + apps.splice(0).forEach(app => app.unmount()) + document.body.innerHTML = '' + vi.clearAllMocks() + }) + + it('reloads the selected file body when refreshing so split and preview modes show latest content', async () => { + let version = 1 + vi.mocked(agentContextApi.listFiles).mockImplementation(async () => ({ + data: [{ + id: 'file-1', + agentId: 'agent-1', + filename: 'AGENTS.md', + fileSize: version === 1 ? 8 : 14, + enabled: true, + sortOrder: 0, + createTime: '2026-08-14T10:00:00', + updateTime: version === 1 ? '2026-08-14T10:00:00' : '2026-08-14T10:01:00', + }], + }) as never) + vi.mocked(agentContextApi.getFile).mockImplementation(async () => ({ + data: { content: version === 1 ? 'old content' : 'latest content' }, + }) as never) + + const host = mountAgentContext() + await eventually(() => { + expect(host.querySelector('.file-item')).not.toBeNull() + }) + + host.querySelector('.file-item')!.click() + await eventually(() => { + expect(host.querySelector('.editor-textarea')?.value).toBe('old content') + }) + host.querySelectorAll('.preview-mode-btn')[2].click() + await eventually(() => { + expect(host.textContent).toContain('old content') + }) + + version = 2 + host.querySelectorAll('.panel-actions .icon-btn')[3].click() + + await eventually(() => { + expect(host.textContent).toContain('latest content') + }) + expect(host.textContent).not.toContain('old content') + }) + + it('ignores stale file loads when the user selects another file before the first request resolves', async () => { + const agentsLoaded = deferred() + vi.mocked(agentApi.list).mockImplementation(async () => { + agentsLoaded.resolve() + return { data: [{ id: 'agent-1', name: '会议管理' }] } as never + }) + vi.mocked(agentContextApi.listFiles).mockResolvedValue({ + data: [ + { + id: 'file-1', + agentId: 'agent-1', + filename: 'AGENTS.md', + fileSize: 8, + enabled: true, + sortOrder: 0, + createTime: '2026-08-14T10:00:00', + updateTime: '2026-08-14T10:00:00', + }, + { + id: 'file-2', + agentId: 'agent-1', + filename: 'MEMORY.md', + fileSize: 14, + enabled: true, + sortOrder: 1, + createTime: '2026-08-14T10:00:00', + updateTime: '2026-08-14T10:01:00', + }, + ], + } as never) + + const agentsFileLoaded = deferred<{ data: { content: string } }>() + vi.mocked(agentContextApi.getFile).mockImplementation((_, filename) => { + if (filename === 'AGENTS.md') { + return agentsFileLoaded.promise as never + } + return Promise.resolve({ data: { content: 'memory latest' } }) as never + }) + + const host = mountAgentContext() + await agentsLoaded.promise + await eventually(() => { + expect(host.querySelectorAll('.file-item')).toHaveLength(2) + }) + + const fileItems = host.querySelectorAll('.file-item') + fileItems[0].click() + fileItems[1].click() + + await eventually(() => { + expect(host.querySelector('.editor-textarea')?.value).toBe('memory latest') + }) + + agentsFileLoaded.resolve({ data: { content: 'agents stale' } }) + + await eventually(() => { + expect(host.querySelector('.editor-textarea')?.value).toBe('memory latest') + }) + }) +})
${value}