fix(desktop): refresh workspace diff previews (#724)

Prefer the live git diff for currently changed workspace files so DIFF previews do not get stuck on stale transcript snapshots. Refresh an existing workspace preview tab when it is opened again instead of only reactivating cached content.

Tested: bun test src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test src/stores/workspacePanelStore.test.ts --run
Tested: bun run check:server
Tested: bun run check:desktop
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 19:02:44 +08:00
parent a2e954b0f9
commit b5161a4709
4 changed files with 153 additions and 50 deletions

View File

@ -342,7 +342,7 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().getMode('session-preview-mode')).toBe('workspace')
})
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
it('opens preview tabs, supports multiple kinds, and refreshes duplicates without persistence', async () => {
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
@ -364,7 +364,7 @@ describe('workspacePanelStore', () => {
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1)
expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(2)
expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1)
const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview']
@ -391,6 +391,34 @@ describe('workspacePanelStore', () => {
}
})
it('refreshes an existing preview tab when the same path is opened again', async () => {
mocks.getWorkspaceDiffMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+first',
})
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+latest',
})
await useWorkspacePanelStore.getState().openPreview('session-refresh', 'src/a.ts', 'diff')
await useWorkspacePanelStore.getState().openPreview('session-refresh', 'src/a.ts', 'diff')
expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(2)
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-refresh']).toMatchObject([
{
id: 'diff:src/a.ts',
kind: 'diff',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+latest',
},
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-refresh']).toBe('diff:src/a.ts')
})
it('closes exact tab id and preserves sibling preview for the same path', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',

View File

@ -457,50 +457,64 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
const requestId = nextRequestId(previewRequestIds, requestKey)
if (existing) {
set((state) => ({
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: null,
},
},
}))
return
}
} else {
const baseTab: WorkspacePreviewTab = {
id: tabId,
path,
kind,
title: getPathTitle(path),
state: 'loading',
}
const requestId = nextRequestId(previewRequestIds, requestKey)
const baseTab: WorkspacePreviewTab = {
id: tabId,
path,
kind,
title: getPathTitle(path),
state: 'loading',
set((state) => ({
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: null,
},
},
}))
}
set((state) => ({
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: null,
},
},
}))
try {
if (kind === 'diff') {
const result = await sessionsApi.getWorkspaceDiff(sessionId, path)

View File

@ -187,6 +187,34 @@ describe('WorkspaceService', () => {
expect(diff.diff?.length).toBeGreaterThan(0)
})
it('prefers the live git diff over stale transcript edits for a currently changed file', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(
async (sessionId) => sessionId === 'session-1' ? repoDir : null,
async () => [{
id: 'assistant-1',
type: 'tool_use',
timestamp: new Date().toISOString(),
content: [{
type: 'tool_use',
name: 'Edit',
input: {
file_path: 'tracked.txt',
old_string: 'before\n',
new_string: 'model snapshot\n',
},
}],
}],
)
const diff = await service.getDiff('session-1', 'tracked.txt')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('diff --git a/tracked.txt b/tracked.txt')
expect(diff.diff).toContain('+after')
expect(diff.diff).not.toContain('+model snapshot')
})
it('returns explicit non-git and missing-workdir states', async () => {
const nonGitDir = await makeTempDir('workspace-service-non-git-')
const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing')

View File

@ -562,25 +562,27 @@ export class WorkspaceService {
}
}
const sessionDiff = await this.getSessionDiff(sessionId, resolvedPath.relativePath)
if (sessionDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff }
}
const fileHistoryDiff = await this.getFileHistoryDiff(
sessionId,
resolvedPath.workspaceRoot,
resolvedPath.relativePath,
)
if (fileHistoryDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: fileHistoryDiff }
}
const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot)
if (repoInfo.kind === 'not_git_repo') {
const storedDiff = await this.getStoredWorkspaceDiff(
sessionId,
resolvedPath.workspaceRoot,
resolvedPath.relativePath,
)
if (storedDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: storedDiff }
}
return { state: 'not_git_repo', path: resolvedPath.relativePath }
}
if (repoInfo.kind === 'error') {
const storedDiff = await this.getStoredWorkspaceDiff(
sessionId,
resolvedPath.workspaceRoot,
resolvedPath.relativePath,
)
if (storedDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: storedDiff }
}
return {
state: 'error',
path: resolvedPath.relativePath,
@ -590,6 +592,14 @@ export class WorkspaceService {
const statusEntries = await this.getStatusEntries(repoInfo.repoRoot)
if (statusEntries.kind === 'error') {
const storedDiff = await this.getStoredWorkspaceDiff(
sessionId,
resolvedPath.workspaceRoot,
resolvedPath.relativePath,
)
if (storedDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: storedDiff }
}
return {
state: 'error',
path: resolvedPath.relativePath,
@ -613,6 +623,14 @@ export class WorkspaceService {
)
if (!statusEntry) {
const storedDiff = await this.getStoredWorkspaceDiff(
sessionId,
resolvedPath.workspaceRoot,
resolvedPath.relativePath,
)
if (storedDiff) {
return { state: 'ok', path: resolvedPath.relativePath, diff: storedDiff }
}
return { state: 'missing', path: resolvedPath.relativePath }
}
@ -650,6 +668,21 @@ export class WorkspaceService {
return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff }
}
private async getStoredWorkspaceDiff(
sessionId: string,
workspaceRoot: string,
relativePath: string,
): Promise<string | null> {
const sessionDiff = await this.getSessionDiff(sessionId, relativePath)
if (sessionDiff) return sessionDiff
return await this.getFileHistoryDiff(
sessionId,
workspaceRoot,
relativePath,
)
}
private async getSessionDiff(
sessionId: string,
relativePath: string,