fix: handle memory empty state and tool autoscroll (#728 #729)

Tested:
- bun test src/server/__tests__/memory.test.ts
- cd desktop && bun run test -- src/__tests__/memorySettings.test.tsx
- cd desktop && bun run test -- src/components/chat/MessageList.test.tsx -t "active tool input"
- cd desktop && bun run check:desktop
- bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 19:46:05 +08:00
parent f5a97ac21c
commit 4b6c855eda
7 changed files with 140 additions and 8 deletions

View File

@ -163,6 +163,33 @@ describe('MemorySettings', () => {
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs')
})
it('does not select a missing current project with no memory files', async () => {
memoryApiMock.listProjects.mockResolvedValue({
projects: [
{
id: '-programs-claude-code',
label: 'C:\\Programs\\claude-code',
memoryDir: 'C:\\Users\\HUAWEI\\.claude\\projects\\-programs-claude-code\\memory',
exists: false,
fileCount: 0,
isCurrent: true,
},
],
})
render(<MemorySettings />)
await waitFor(() => {
expect(screen.getAllByText('Programs/claude-code')).toHaveLength(1)
})
expect(screen.getByText('Missing')).toBeInTheDocument()
expect(screen.getAllByText('No file selected').length).toBeGreaterThan(0)
expect(screen.getByText('Select a project.')).toBeInTheDocument()
expect(screen.queryByText(/HUAWEI/)).not.toBeInTheDocument()
expect(memoryApiMock.listFiles).not.toHaveBeenCalled()
expect(useMemoryStore.getState().selectedProjectId).toBeNull()
})
it('lets the markdown editor fill the remaining detail pane height', async () => {
render(<MemorySettings />)

View File

@ -2269,6 +2269,80 @@ describe('MessageList nested tool calls', () => {
expect(scrollTop).toBe(600)
})
it('keeps auto-scrolling when active tool input updates in place', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'tool_executing',
streamingToolInput: '{"file_path":"/tmp/app.vue","content":"<template>',
activeToolUseId: 'write-1',
activeToolName: 'Write',
messages: [
{
id: 'tool-write',
type: 'tool_use',
toolName: 'Write',
toolUseId: 'write-1',
input: {},
partialInput: '{"file_path":"/tmp/app.vue","content":"<template>',
isPending: true,
timestamp: 1,
},
],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 552
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
scrollIntoView.mockClear()
await waitForProgrammaticScrollReset()
fireEvent.scroll(scroller)
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
streamingToolInput: '{"file_path":"/tmp/app.vue","content":"<template>\\n<section>latest</section>',
streamingResponseChars: 32,
messages: [
{
...state.sessions[ACTIVE_TAB]!.messages[0] as Extract<UIMessage, { type: 'tool_use' }>,
input: { file_path: '/tmp/app.vue', content: '<template>\n<section>latest</section>' },
partialInput: '{"file_path":"/tmp/app.vue","content":"<template>\\n<section>latest</section>',
},
],
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('2 lines · 36 chars')).toBeTruthy()
})
expect(scrollIntoView).not.toHaveBeenCalled()
expect(scrollTop).toBe(600)
})
it('keeps auto-scrolling without reading scroll geometry synchronously', async () => {
useChatStore.setState({
sessions: {

View File

@ -1349,6 +1349,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
const streamingText = sessionState?.streamingText ?? ''
const streamingToolInput = sessionState?.streamingToolInput ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
const activeAskUserQuestionToolUseId =
@ -1603,7 +1604,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}
scrollToBottom('auto')
}, [messages.length, resolvedSessionId, scrollToBottom, streamingText])
}, [messages.length, resolvedSessionId, scrollToBottom, streamingText, streamingToolInput])
const handleJumpToLatest = useCallback(() => {
scrollToBottom('auto')

View File

@ -2,6 +2,10 @@ import { create } from 'zustand'
import { memoryApi } from '../api/memory'
import type { MemoryFile, MemoryFileDetail, MemoryProject } from '../types/memory'
function canSelectMemoryProject(project: MemoryProject): boolean {
return project.exists || project.fileCount > 0
}
type MemoryStore = {
projects: MemoryProject[]
files: MemoryFile[]
@ -41,12 +45,26 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
set({ isLoadingProjects: true, error: null })
try {
const { projects } = await memoryApi.listProjects(cwd)
const current = projects.find((project) => project.isCurrent)
const selectableProjects = projects.filter(canSelectMemoryProject)
const current = selectableProjects.find((project) => project.isCurrent)
const previousSelectedProjectId = get().selectedProjectId
const selectedProjectId =
get().selectedProjectId && projects.some((project) => project.id === get().selectedProjectId)
? get().selectedProjectId
: current?.id ?? projects[0]?.id ?? null
set({ projects, selectedProjectId, isLoadingProjects: false })
previousSelectedProjectId && selectableProjects.some((project) => project.id === previousSelectedProjectId)
? previousSelectedProjectId
: current?.id ?? selectableProjects[0]?.id ?? null
set({
projects,
selectedProjectId,
isLoadingProjects: false,
...(selectedProjectId === previousSelectedProjectId
? {}
: {
files: [],
selectedFile: null,
draftContent: '',
lastSavedAt: null,
}),
})
} catch (err) {
set({ error: (err as Error).message, isLoadingProjects: false })
}

View File

@ -40,6 +40,18 @@ afterEach(async () => {
})
describe('memory API', () => {
it('does not fabricate a current memory project when no memory directory exists', async () => {
const cwd = path.join(tmpDir, 'fresh-install', 'app')
const projectsRes = await request('GET', `/api/memory/projects?cwd=${encodeURIComponent(cwd)}`)
expect(projectsRes.status).toBe(200)
const projectsBody = await projectsRes.json() as {
projects: Array<{ id: string; isCurrent: boolean; exists: boolean; fileCount: number }>
}
expect(projectsBody.projects).toEqual([])
})
it('lists current project memory files with frontmatter metadata', async () => {
const cwd = path.join(tmpDir, 'workspace', 'app')
const projectId = sanitizePath(cwd)

View File

@ -241,7 +241,7 @@ describe('SearchService', () => {
})
it('should return empty session results when no projects dir exists', async () => {
const results = await service.searchSessions('test')
const { results } = await service.searchSessions('test')
expect(results).toEqual([])
})
})

View File

@ -114,7 +114,7 @@ async function listMemoryProjects(cwd?: string): Promise<MemoryProject[]> {
)
return resolved
.filter((project) => project.isCurrent || project.exists || project.fileCount > 0)
.filter((project) => project.exists)
.sort((a, b) => {
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1
if (a.fileCount !== b.fileCount) return b.fileCount - a.fileCount