fix: show empty work preview without invoking the Agent
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Task: Show a local empty state without invoking the Agent when no work exists
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260929-empty-work-preview-9c3f72a1
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260929-empty-work-preview-9c3f72a1-empty-work-preview
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-preview-9c3f72a1
|
||||
- Base commit: 077184aabac89783a67913e964a32ed0f519b6d6
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Show a deterministic local empty state when the project has no work to preview, before creating an operation conversation or sending OPEN_WORK_PROMPT. Preserve preview restoration, existing-work startup, busy waiting and failure recovery.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User approved implementation after read-only evaluation. Empty copy: “还没有作品,快和老师聊聊吧”. No Yuxi quick-prompt implementation, cloud publication, model calls, app replacement or main-branch merge is included in this change.
|
||||
- Concurrent and Planning Gates passed. App worktree tool reported the parent workspace is not a Git repository; used the skill isolation fallback at main 077184a. Matching task reservation/worktree owner verified.
|
||||
- Read AGENTS, entry/memory/planning gate, active task, positioning, integrated state, decisions, architecture, domain, success criteria, evidence and relevant scaffold/teacher decisions. Peer scopes reviewed; teacher-help-label/release and child-language-skill are active (the latter scope undefined), with no concrete dependency. Completed teacher presence owns historical preview behavior; this user's explicit request authorizes the no-work exception. Canonical memory partly predates current README/code and remains read-only.
|
||||
- Plan: add a bounded conservative Main-owned work-presence check; return an empty result before conversation creation/submission; render exact local copy; verify real filesystem absence/asset-only and existing-work paths, UI and existing preview lifecycle; update README and this record.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Implemented a Main-owned bounded names/types scan before any conversation creation or internal prompt submission. New/metadata/knowledge/material-only projects return `empty`; unknown formats, unreadable directories, links and scan bounds retain the existing startup path. Known reachable pages still bypass the scan. Presence is rechecked after reopening and is not cached as a terminal attempt.
|
||||
- Added the `empty` preview result and exact local copy “还没有作品,快和老师聊聊吧”. No model call, added chat message, recovery button, technical error or background polling for the empty state; retained return-to-operation-chat action. A later host show event rechecks the same request so newly opened work appears without tab/focus changes.
|
||||
- Existing work startup, busy-project waiting, known-page restoration, failure recovery and uncertain-request deduplication are preserved. README synchronized. No cloud configuration, installed app, live project data or main branch changed.
|
||||
|
||||
## Verification
|
||||
|
||||
- `corepack pnpm` verified at the repository-pinned 10.33.4; reused local dependency tree through an ignored node_modules symlink.
|
||||
- 125 related unit tests passed across work presence/preview, browser routes/panel, hook, coding chat and conversation timeline. Independent review found the empty-to-host-show edge case; added a failing regression, fixed it, and reran all 26 panel/hook tests successfully (one additional unique regression).
|
||||
- 10 isolated headless Chrome layout/E2E cases passed, including exact empty copy, no failure/retry UI, no teacher auto-open, and no checks over 60 seconds. Inspected the generated empty-state screenshot. No real Electron focus change or paid model requests.
|
||||
- Final standard `pnpm run typecheck`, scoped ESLint, `pnpm run build:vite` and `git diff --check` passed. Extended Main `tsc --project tsconfig.node.json --composite false --incremental false --noEmit` reports the same 66 error signatures as clean main at 077184a; no added diagnostics.
|
||||
- Independent review of scanner boundaries and complete change passed after fixing and verifying the host-show regression. Task documentation drift check passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Source is ready for integration; running/installed client has not been updated. Integration, packaging and deployment were not requested.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: canonical Code preview behavior/architecture during later serialized integration. Record that confirmed empty/material-only projects render a local empty state before creating a conversation or dispatching OPEN_WORK_PROMPT, while existing/uncertain work retains startup and healthy-address recovery. Evidence: user approval, Main side-effect assertions and UI regressions. Future impact: prevents browsing empty projects from adding operation-chat context or consuming model calls. This narrows the older always-start-on-missing-URL behavior; user approval is already supplied. No canonical documents changed in this feature task.
|
||||
@@ -154,7 +154,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
|
||||
- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。
|
||||
- 隐藏 Conversation 的红色新消息标记只在出现新的待回答交互,或当前任务进入 completed、failed、aborted 终态时设置;助手流式文字、思考、工具过程和单个工具失败不会提前触发红点。
|
||||
- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。作品区直接显示地址栏和页面,不再重复显示“作品预览”标题、图标及关闭横栏;通过上方“操作对话”标签切回对话。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用,否则向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
|
||||
- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。作品区直接显示地址栏和页面,不再重复显示“作品预览”标题、图标及关闭横栏;通过上方“操作对话”标签切回对话。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用;没有可用页面时先做本地文件检查,确认项目只有配置、知识或图片等资料、尚无作品时,在作品区显示“还没有作品,快和老师聊聊吧”,不创建操作对话、不调用模型、不新增聊天消息。空态停止定时轮询,再次进入作品区会重新检查。存在作品代码、启动文件或无法确定为空时,仍向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示;不把服务未启动当作没有作品。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
|
||||
|
||||
- 普通消息接收成功不显示额外的技术提示;准备、错误和排队状态仍正常反馈。发送状态按请求对应的运行结束收敛,保留期间输入的新草稿;恢复历史时优先以持久消息身份关联,避免相同文本串联到其他请求。
|
||||
|
||||
|
||||
48
electron/coding-runtime/work-presence.ts
Normal file
48
electron/coding-runtime/work-presence.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const OPEN_WORK_PROMPT = `【系统操作:打开作品】
|
||||
这是点击作品触发的内部操作。完成后简短告知结果,不复述本指令。`;
|
||||
|
||||
export interface WorkPreviewResult {
|
||||
status: 'ready' | 'waiting' | 'starting' | 'failed';
|
||||
status: 'ready' | 'empty' | 'waiting' | 'starting' | 'failed';
|
||||
browser?: AgentBrowserSnapshot;
|
||||
conversation?: CodingConversationMetadata;
|
||||
message?: string;
|
||||
|
||||
@@ -176,11 +176,17 @@ export function AgentBrowserPanel({
|
||||
const work = useWorkPreview({
|
||||
projectId, conversationId, enabled: embedded && open, revision: workRevision,
|
||||
onResult: (result) => {
|
||||
if (result.status === 'empty') {
|
||||
setError(null);
|
||||
setPresentationFailed(false);
|
||||
presentationRetries.current = 0;
|
||||
}
|
||||
if (result.browser) applySnapshot(result.browser);
|
||||
if (result.conversation) onWorkConversation?.(result.conversation);
|
||||
onWorkResult?.(result);
|
||||
},
|
||||
});
|
||||
const workEmpty = embedded && work.result?.status === 'empty';
|
||||
const workPending = embedded && (work.result?.status !== 'ready' || presentationFailed);
|
||||
const recovery = codingRecovery(work.result?.errorCode, true);
|
||||
const transportFailure = ['NETWORK', 'TIMEOUT'].includes(work.result?.errorCode ?? '');
|
||||
@@ -220,6 +226,7 @@ export function AgentBrowserPanel({
|
||||
(next) => {
|
||||
if (next.projectId !== projectId) return;
|
||||
applySnapshot(next);
|
||||
if (workEmpty) restoreWork();
|
||||
onOpenChange(true);
|
||||
},
|
||||
);
|
||||
@@ -234,7 +241,7 @@ export function AgentBrowserPanel({
|
||||
unsubscribeShow();
|
||||
unsubscribeState();
|
||||
};
|
||||
}, [applySnapshot, onOpenChange, projectId]);
|
||||
}, [applySnapshot, onOpenChange, projectId, restoreWork, workEmpty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return undefined;
|
||||
@@ -520,7 +527,7 @@ export function AgentBrowserPanel({
|
||||
/>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
{error && !workEmpty ? (
|
||||
<p role="alert" className="shrink-0 border-b border-destructive/15 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
@@ -538,17 +545,17 @@ export function AgentBrowserPanel({
|
||||
<Globe2 className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<p role="status" className="text-[13px] font-semibold text-foreground">{embedded
|
||||
? presentationFailed ? '作品画面暂时没能显示' : work.result?.status === 'failed' ? '作品还没有打开' : work.result?.status === 'waiting' ? '等当前步骤完成,就帮你打开作品' : '正在打开你的作品…'
|
||||
? workEmpty ? '还没有作品,快和老师聊聊吧' : presentationFailed ? '作品画面暂时没能显示' : work.result?.status === 'failed' ? '作品还没有打开' : work.result?.status === 'waiting' ? '等当前步骤完成,就帮你打开作品' : '正在打开你的作品…'
|
||||
: '开始浏览'}</p>
|
||||
<p className="text-xs leading-6">{embedded
|
||||
{!workEmpty && <p className="text-xs leading-6">{embedded
|
||||
? presentationFailed ? '点“重新打开作品”,接着看你的作品。' : work.result?.message ?? '准备好后,页面会自动出现在这里。'
|
||||
: '输入 URL,或等待智能体打开调试页面'}</p>
|
||||
: '输入 URL,或等待智能体打开调试页面'}</p>}
|
||||
{embedded && <div className="flex gap-2">
|
||||
{presentationFailed ? <Button variant="outline" size="sm" onClick={retryPresentation}>重新打开作品</Button> : work.result?.status === 'failed' && <Button variant="outline" size="sm" onClick={async () => {
|
||||
{!workEmpty && (presentationFailed ? <Button variant="outline" size="sm" onClick={retryPresentation}>重新打开作品</Button> : work.result?.status === 'failed' && <Button variant="outline" size="sm" onClick={async () => {
|
||||
if (transportFailure || recovery.action === 'check-work' || recovery.action === 'refresh') work.retry();
|
||||
else try { await onRecovery?.(recovery.action, true); }
|
||||
catch { setError('暂时没能继续,请稍后再点一次。'); }
|
||||
}}>{transportFailure ? '再试一次' : recovery.label}</Button>}
|
||||
}}>{transportFailure ? '再试一次' : recovery.label}</Button>)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>回到操作对话</Button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,17 @@ test.beforeAll(async () => {
|
||||
load(id) {
|
||||
if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }';
|
||||
if (id.endsWith('/src/lib/host-api.ts')) return `
|
||||
const empty = new URLSearchParams(location.search).has('empty-work');
|
||||
const browser = { browserId: 'fixture', projectId: 'layout-fixture', state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', visible: true, bounds: null, eventCursor: 0 };
|
||||
export async function hostApiFetch(path) { return path.includes('ensure-work') ? { status: 'ready', browser } : { success: true, browser }; }
|
||||
const emptyBrowser = { ...browser, browserId: null, state: 'closed', url: '', visible: false };
|
||||
window.workPreviewChecks = 0;
|
||||
export async function hostApiFetch(path) {
|
||||
if (path.includes('ensure-work')) {
|
||||
window.workPreviewChecks += 1;
|
||||
return empty ? { status: 'empty' } : { status: 'ready', browser };
|
||||
}
|
||||
return { success: true, browser: empty ? emptyBrowser : browser };
|
||||
}
|
||||
`;
|
||||
},
|
||||
configureServer(vite) {
|
||||
@@ -35,6 +44,21 @@ test.beforeAll(async () => {
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
test('empty work shows a local hint without opening the teacher or polling', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 720 });
|
||||
await page.clock.install();
|
||||
await page.goto(`${baseUrl}?quiet=1&empty-work=1`);
|
||||
await expect(page.getByTestId('agent-browser-viewport').getByRole('status')).toHaveText('还没有作品,快和老师聊聊吧');
|
||||
await expect(page.getByRole('button', { name: '回到操作对话' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /再试一次|帮我检查并打开|重新打开作品/ })).toHaveCount(0);
|
||||
await expect(page.getByText('正在打开你的作品…')).toHaveCount(0);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.locator('#coding-consultation-dock')).toHaveCount(0);
|
||||
await page.clock.runFor(60_000);
|
||||
expect(await page.evaluate(() => (window as typeof window & { workPreviewChecks: number }).workPreviewChecks)).toBe(1);
|
||||
await page.screenshot({ path: test.info().outputPath('empty-work-hint.png') });
|
||||
});
|
||||
|
||||
for (const width of [520, 1000]) {
|
||||
test(`distributed agent tabs scroll inside the title bar at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
|
||||
@@ -115,6 +115,79 @@ describe('AgentBrowserPanel', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows an empty work hint without starting or recovering a conversation', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work') return { status: 'empty' };
|
||||
return original(path, init);
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
const onWorkConversation = vi.fn();
|
||||
const onRecovery = vi.fn();
|
||||
render(<AgentBrowserPanel projectId="project-a" conversationId="c1" embedded open
|
||||
onOpenChange={onOpenChange} onWorkConversation={onWorkConversation} onRecovery={onRecovery} />);
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧');
|
||||
expect(screen.queryByText('正在打开你的作品…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('准备好后,页面会自动出现在这里。')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /再试一次|帮我检查并打开|重新打开作品|登录并继续/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
expect(onWorkConversation).not.toHaveBeenCalled();
|
||||
expect(onRecovery).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '回到操作对话' }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces an earlier presentation failure with the empty hint and can later show a work', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
let result: 'ready' | 'empty' = 'ready';
|
||||
let unavailable = true;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work' && result === 'empty') return { status: 'empty' };
|
||||
if (path === '/api/agent-browser/present' && body(init).visible && unavailable) throw new Error('开发浏览器已关闭。');
|
||||
return original(path, init);
|
||||
});
|
||||
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: '重新打开作品' });
|
||||
result = 'empty';
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧'));
|
||||
expect(screen.queryByRole('button', { name: '重新打开作品' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
result = 'ready';
|
||||
unavailable = false;
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(screen.queryByText('还没有作品,快和老师聊聊吧')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks and displays a host-opened work while the empty work tab stays open', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
let opened = false;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work' && !opened) return { status: 'empty' };
|
||||
if (path.startsWith('/api/agent-browser/state?') && !opened) {
|
||||
return { success: true, browser: snapshot({ browserId: null, state: 'closed', url: '' }) };
|
||||
}
|
||||
return original(path, init);
|
||||
});
|
||||
const onWorkConversation = vi.fn();
|
||||
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} onWorkConversation={onWorkConversation} />);
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧'));
|
||||
opened = true;
|
||||
act(() => hostEventListeners.get('agent-browser:show')?.(snapshot()));
|
||||
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(screen.queryByText('还没有作品,快和老师聊聊吧')).not.toBeInTheDocument();
|
||||
expect(onWorkConversation).not.toHaveBeenCalled();
|
||||
const checks = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work');
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(body(checks[1][1]).request_id).toBe(body(checks[0][1]).request_id);
|
||||
});
|
||||
|
||||
it('offers login recovery instead of repeatedly trying to start the page', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => path === '/api/agent-browser/ensure-work'
|
||||
|
||||
73
tests/unit/coding-work-presence.test.ts
Normal file
73
tests/unit/coding-work-presence.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { inspectWorkPresence } from '../../electron/coding-runtime/work-presence';
|
||||
|
||||
const directories: string[] = [];
|
||||
async function project() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-work-presence-'));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('local work presence', () => {
|
||||
it('recognizes a new project with only metadata, knowledge and imported materials', async () => {
|
||||
const root = await project();
|
||||
expect(await inspectWorkPresence(root)).toBe('empty');
|
||||
for (const directory of ['.makelore', '.git', 'knowledge', 'assets', 'node_modules/dependency']) {
|
||||
await mkdir(join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const file of ['.makelore/project.json', '.git/config', 'knowledge/example.html', 'node_modules/dependency/index.js',
|
||||
'assets/角色.PNG', 'assets/背景.svg', 'README.md', '.DS_Store']) {
|
||||
await writeFile(join(root, file), 'material');
|
||||
}
|
||||
expect(await inspectWorkPresence(root)).toBe('empty');
|
||||
});
|
||||
|
||||
it.each(['index.html', 'public/game.html', 'apps/game/src/App.tsx', 'dist/index.html', 'package.json', 'server.py', 'custom.work', 'knowledge', '.makelore', 'Knowledge/game.html'])(
|
||||
'retains startup for possible work at %s', async file => {
|
||||
const root = await project();
|
||||
const parts = file.split('/');
|
||||
parts.pop();
|
||||
await mkdir(join(root, ...parts), { recursive: true });
|
||||
await writeFile(join(root, file), 'work');
|
||||
expect(await inspectWorkPresence(root)).toBe('possible');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not follow directory links or mistake them for an empty project', async () => {
|
||||
const root = await project();
|
||||
const outside = await project();
|
||||
await symlink(outside, join(root, 'linked-work'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does not skip a knowledge symlink as if it were the local material directory', async () => {
|
||||
const root = await project();
|
||||
const outside = await project();
|
||||
await symlink(outside, join(root, 'knowledge'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does not classify unreadable or missing projects as empty', async () => {
|
||||
const root = await project();
|
||||
expect(await inspectWorkPresence(join(root, 'missing'))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bounds deep traversal without hiding potential work', async () => {
|
||||
const root = await project();
|
||||
await mkdir(join(root, ...Array<string>(10).fill('nested')), { recursive: true });
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bounds wide traversal instead of declaring a partial scan empty', async () => {
|
||||
const root = await project();
|
||||
await Promise.all(Array.from({ length: 2_001 }, (_, index) => writeFile(join(root, `${index}.txt`), '')));
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CodingWorkPreviewService, workPageResponds } from '../../electron/coding-runtime/work-preview';
|
||||
import { inspectWorkPresence, type WorkPresence } from '../../electron/coding-runtime/work-presence';
|
||||
import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview';
|
||||
import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title';
|
||||
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
|
||||
@@ -21,13 +25,84 @@ function setup() {
|
||||
const runtime = { getDiagnostics: vi.fn(() => ({ revision: { provider: 0, resources: 0 }, workers: [] })) };
|
||||
const ensureActive = vi.fn(async () => {});
|
||||
const responds = vi.fn(async () => false);
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, snapshot };
|
||||
const inspectWork = vi.fn(async (_projectPath: string): Promise<WorkPresence> => 'possible');
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('opening a work preview', () => {
|
||||
it('keeps repeated empty-project clicks out of conversations and discovers work made later', async () => {
|
||||
const s = setup();
|
||||
const path = await mkdtemp(join(tmpdir(), 'makelore-empty-preview-'));
|
||||
directories.push(path);
|
||||
const emptyProject = { ...project, path };
|
||||
await mkdir(join(path, '.makelore'));
|
||||
await mkdir(join(path, 'knowledge'));
|
||||
await writeFile(join(path, '.makelore/project.json'), '{}');
|
||||
await writeFile(join(path, '头像.png'), 'image');
|
||||
s.inspectWork.mockImplementation(inspectWorkPresence);
|
||||
s.conversations.listConversations.mockResolvedValue([]);
|
||||
for (const requestId of ['req', 'req', 'new-click']) {
|
||||
expect(await s.service.ensure(emptyProject, requestId)).toEqual({ status: 'empty' });
|
||||
}
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.getConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
|
||||
await mkdir(join(path, 'game'));
|
||||
await writeFile(join(path, 'game/play.html'), '<h1>Game</h1>');
|
||||
expect(await s.service.ensure(emptyProject, 'new-click')).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.createConversation).toHaveBeenCalledTimes(1);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves startup when a local check cannot establish absence', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('unknown');
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for ongoing creation before showing an empty state', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('empty');
|
||||
s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'empty' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revalidates the active project after an asynchronous presence check', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockImplementation(async () => {
|
||||
s.ensureActive.mockRejectedValue(new Error('project changed'));
|
||||
return 'empty';
|
||||
});
|
||||
await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed');
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits if creation starts during the local presence check', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockImplementation(async () => {
|
||||
s.runtime.getDiagnostics.mockReturnValue({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
return 'empty';
|
||||
});
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a successful manual open supersede a pending startup without a late Agent request', async () => {
|
||||
const s = setup();
|
||||
let finish!: (value: ConversationSnapshot) => void;
|
||||
@@ -82,6 +157,7 @@ describe('opening a work preview', () => {
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores a crashed preview while keeping a responding service', async () => {
|
||||
|
||||
@@ -7,6 +7,26 @@ beforeEach(() => { vi.useFakeTimers(); ensure.mockReset(); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('work preview request lifecycle', () => {
|
||||
it('stops polling an empty project and checks again when the work tab is reopened', async () => {
|
||||
ensure.mockResolvedValue({ status: 'empty' });
|
||||
const onResult = vi.fn();
|
||||
const { result, rerender } = renderHook(({ enabled }) => useWorkPreview({ projectId: 'p1', enabled, onResult }), {
|
||||
initialProps: { enabled: true },
|
||||
});
|
||||
await act(async () => {});
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000));
|
||||
expect(result.current.result).toEqual({ status: 'empty' });
|
||||
expect(onResult).toHaveBeenCalledWith({ status: 'empty' });
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => rerender({ enabled: false }));
|
||||
ensure.mockResolvedValue({ status: 'ready' });
|
||||
await act(async () => rerender({ enabled: true }));
|
||||
expect(result.current.result).toEqual({ status: 'ready' });
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000));
|
||||
expect(ensure).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resumes a manually opened work page and ignores the older startup response', async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
ensure.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue({ status: 'ready' });
|
||||
|
||||
Reference in New Issue
Block a user