This commit is contained in:
吴杨帆 2026-05-31 01:23:29 -04:00 committed by GitHub
commit c7fe612a98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 7 deletions

View file

@ -54,6 +54,7 @@ import {
messageTime, messageTime,
relativeTimeLong, relativeTimeLong,
} from "../utils/chatTime"; } from "../utils/chatTime";
import { filterAgentAttributedProjectFiles } from "../produced-files";
import type { import type {
AgentEvent, AgentEvent,
ChatMessage, ChatMessage,
@ -698,14 +699,14 @@ function inferProducedFilesFromTurn({
if (fileOps.length > 0) return []; if (fileOps.length > 0) return [];
const start = message.startedAt - 1_000; const start = message.startedAt - 1_000;
const end = message.endedAt + 60_000; const end = message.endedAt + 60_000;
return projectFiles return filterAgentAttributedProjectFiles(
.filter((file) => { projectFiles.filter((file) => {
if (file.type === "dir") return false; if (file.type === "dir") return false;
if (!file.name || file.name.startsWith(".")) return false; if (!file.name || file.name.startsWith(".")) return false;
if (file.name.includes("/.")) return false; if (file.name.includes("/.")) return false;
return file.mtime >= start && file.mtime <= end; return file.mtime >= start && file.mtime <= end;
}) }),
.sort((a, b) => b.mtime - a.mtime); ).sort((a, b) => b.mtime - a.mtime);
} }
function isFeedbackEligible({ function isFeedbackEligible({

View file

@ -131,6 +131,7 @@ import type {
SkillSummary, SkillSummary,
} from '../types'; } from '../types';
import { historyWithApiAttachmentContext } from '../api-attachment-context'; import { historyWithApiAttachmentContext } from '../api-attachment-context';
import { filterAgentAttributedProjectFiles } from '../produced-files';
import { import {
commentsToAttachments, commentsToAttachments,
historyWithCommentAttachmentContext, historyWithCommentAttachmentContext,
@ -2108,7 +2109,8 @@ export function ProjectView({
nextFiles = await refreshProjectFiles(); nextFiles = await refreshProjectFiles();
} }
} }
const diff = nextFiles.filter((f) => !beforeFileNames.has(f.name)); const diff =
computeProducedFiles(beforeFileNames, nextFiles) ?? [];
const produced = mergeRecoveredArtifact(diff, recoveredExistingArtifact); const produced = mergeRecoveredArtifact(diff, recoveredExistingArtifact);
if (produced.length > 0) { if (produced.length > 0) {
updateMessageById( updateMessageById(
@ -2667,7 +2669,7 @@ export function ProjectView({
await persistArtifact(parsedArtifact, nextFiles); await persistArtifact(parsedArtifact, nextFiles);
nextFiles = await refreshProjectFiles(); nextFiles = await refreshProjectFiles();
} }
const produced = nextFiles.filter((f) => !beforeFileNames.has(f.name)); const produced = computeProducedFiles(beforeFileNames, nextFiles) ?? [];
setMessages((curr) => { setMessages((curr) => {
const updated = curr.map((m) => const updated = curr.map((m) =>
m.id === assistantId m.id === assistantId
@ -4918,7 +4920,7 @@ export function computeProducedFiles(
): ProjectFile[] | undefined { ): ProjectFile[] | undefined {
if (!beforeNames) return undefined; if (!beforeNames) return undefined;
const set = beforeNames instanceof Set ? beforeNames : new Set(beforeNames); const set = beforeNames instanceof Set ? beforeNames : new Set(beforeNames);
return next.filter((f) => !set.has(f.name)); return filterAgentAttributedProjectFiles(next.filter((f) => !set.has(f.name)));
} }
// Reattach with a recovered (on-disk) artifact must still include any // Reattach with a recovered (on-disk) artifact must still include any

View file

@ -0,0 +1,12 @@
import type { ProjectFile } from './types';
/** User-drawn sketch workspace files are not agent turn outputs (#3089). */
export function isUserSketchProjectFile(file: ProjectFile): boolean {
return file.kind === 'sketch';
}
export function filterAgentAttributedProjectFiles(
files: readonly ProjectFile[],
): ProjectFile[] {
return files.filter((file) => !isUserSketchProjectFile(file));
}

View file

@ -301,4 +301,32 @@ describe('AssistantMessage recovered produced files', () => {
expect(screen.getByText('iphone-device-reveal.mp4')).toBeTruthy(); expect(screen.getByText('iphone-device-reveal.mp4')).toBeTruthy();
}); });
it('does not infer user sketches as turn output files (#3089)', () => {
render(
<AssistantMessage
message={baseMessage({
content: '',
events: [
{ kind: 'status', label: 'starting', detail: 'Claude' } as ChatMessage['events'][number],
{ kind: 'status', label: 'initializing', detail: 'claude-opus' } as ChatMessage['events'][number],
],
producedFiles: [],
})}
streaming={false}
projectId="proj-1"
projectFiles={[
{
name: 'board.sketch.json',
path: 'board.sketch.json',
size: 2048,
mtime: 1700000004,
kind: 'sketch',
} as ProjectFile,
]}
/>,
);
expect(screen.queryByText('board.sketch.json')).toBeNull();
});
}); });

View file

@ -151,6 +151,17 @@ describe('computeProducedFiles', () => {
expect(produced?.map((f) => f.name)).toEqual(['new.pptx']); expect(produced?.map((f) => f.name)).toEqual(['new.pptx']);
}); });
it('excludes user sketch files from turn output attribution (#3089)', () => {
const before = ['existing.html'];
const next = [
{ name: 'existing.html', path: '/p/existing.html', size: 1, updatedAt: 0, kind: 'html' },
{ name: 'board.sketch.json', path: '/p/board.sketch.json', size: 2, updatedAt: 0, kind: 'sketch' },
{ name: 'new.pptx', path: '/p/new.pptx', size: 3, updatedAt: 0, kind: 'pdf' },
];
const produced = computeProducedFiles(before, next as never);
expect(produced?.map((f) => f.name)).toEqual(['new.pptx']);
});
it('returns undefined when no baseline is provided', () => { it('returns undefined when no baseline is provided', () => {
expect(computeProducedFiles(undefined, [] as never)).toBeUndefined(); expect(computeProducedFiles(undefined, [] as never)).toBeUndefined();
}); });

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
filterAgentAttributedProjectFiles,
isUserSketchProjectFile,
} from '../src/produced-files';
import type { ProjectFile } from '../src/types';
function file(name: string, kind: ProjectFile['kind']): ProjectFile {
return {
name,
path: name,
size: 1,
mtime: 0,
kind,
mime: kind === 'sketch' ? 'application/json' : 'text/html',
};
}
describe('isUserSketchProjectFile', () => {
it('returns true for sketch workspace files', () => {
expect(isUserSketchProjectFile(file('board.sketch.json', 'sketch'))).toBe(true);
});
it('returns false for agent-generated artifacts', () => {
expect(isUserSketchProjectFile(file('index.html', 'html'))).toBe(false);
});
});
describe('filterAgentAttributedProjectFiles', () => {
it('drops sketch files from turn output attribution (#3089)', () => {
const filtered = filterAgentAttributedProjectFiles([
file('deck.html', 'html'),
file('notes.sketch.json', 'sketch'),
]);
expect(filtered.map((entry) => entry.name)).toEqual(['deck.html']);
});
});