Merge empty-work preview into main

This commit is contained in:
鲨鱼辣椒
2026-09-29 20:15:44 +08:00
12 changed files with 450 additions and 15 deletions

View File

@@ -0,0 +1,48 @@
import { lstat, opendir, realpath } from 'node:fs/promises';
import path from 'node:path';
export type WorkPresence = 'empty' | 'possible' | 'unknown';
// Only prove absence. Unknown project formats must retain the existing startup
// path; a missing root index.html or an offline server does not prove emptiness.
const IGNORED_DIRECTORIES = new Set(['.git', '.makelore', '.vscode', '.idea', 'node_modules']);
const IGNORED_FILES = new Set(['.ds_store', 'thumbs.db', '.git', '.gitignore', '.gitattributes', '.editorconfig', 'license', 'readme']);
const MATERIAL_EXTENSIONS = new Set([
'.md', '.txt', '.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv',
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif',
'.mp3', '.wav', '.ogg', '.m4a', '.mp4', '.webm', '.mov',
'.woff', '.woff2', '.ttf', '.otf',
]);
const MAX_ENTRIES = 2_000;
const MAX_DEPTH = 8;
/** Inspect names/types only, without reading contents, following links or running a model. */
export async function inspectWorkPresence(projectPath: string): Promise<WorkPresence> {
try {
const root = await realpath(projectPath);
const pending = [{ directory: root, depth: 0 }];
let visited = 0;
while (pending.length) {
const { directory, depth } = pending.pop()!;
// Revalidate queued directories in case a project edit replaced one with a link.
if ((await lstat(directory)).isSymbolicLink() || await realpath(directory) !== directory) return 'unknown';
const entries = await opendir(directory);
for await (const entry of entries) {
if (++visited > MAX_ENTRIES) return 'unknown';
const name = entry.name.toLowerCase();
if (entry.isSymbolicLink()) return 'unknown';
if (entry.isDirectory()) {
if (IGNORED_DIRECTORIES.has(entry.name) || (depth === 0 && entry.name === 'knowledge')) continue;
if (depth >= MAX_DEPTH) return 'unknown';
pending.push({ directory: path.join(directory, entry.name), depth: depth + 1 });
} else if (entry.isFile()) {
if (!IGNORED_FILES.has(name) && !MATERIAL_EXTENSIONS.has(path.extname(name))) return 'possible';
} else return 'unknown';
}
}
return 'empty';
} catch {
// Unreadable, removed or changing directories are not evidence of no work.
return 'unknown';
}
}

View File

@@ -4,6 +4,7 @@ import type { CodingConversationRuntime } from './contracts';
import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-work-preview';
import { codingRecovery } from '../../shared/coding-recovery';
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
import { inspectWorkPresence, type WorkPresence } from './work-presence';
type Project = { id: string; path: string };
type Attempt = {
@@ -43,6 +44,7 @@ export class CodingWorkPreviewService {
runtime: Pick<CodingConversationRuntime, 'getDiagnostics'>;
ensureActive(project: Project): Promise<void>;
responds?(url: string): Promise<boolean>;
inspectWork?(projectPath: string): Promise<WorkPresence>;
}) {}
remember(project: Project, snapshot: AgentBrowserSnapshot): void {
@@ -112,10 +114,19 @@ export class CodingWorkPreviewService {
const list = await conversations.listConversations(project.id);
const projectIds = new Set(list.map(({ id }) => id));
const busy = this.deps.runtime.getDiagnostics().workers.some((worker) => (
const busy = () => this.deps.runtime.getDiagnostics().workers.some((worker) => (
projectIds.has(worker.conversationId) && ['starting', 'queued', 'running'].includes(worker.stage)
));
if (busy) return { status: 'waiting' };
if (busy()) return { status: 'waiting' };
const presence = await (this.deps.inspectWork ?? inspectWorkPresence)(project.path);
await this.deps.ensureActive(project);
if (this.pages.has(project.id)) return this.check(project, requestId, conversationId);
if (busy()) return { status: 'waiting' };
if (presence === 'empty') {
// No cached terminal attempt: the same tab/request can discover newly made work.
this.attempts.delete(project.id);
return { status: 'empty' };
}
let conversation = attempt.conversationId
? await conversations.getConversation(attempt.conversationId)
: list.find((item) => !item.archivedAt);