Merge empty-work preview into main
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Task: Merge empty work preview into current main
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260929-empty-work-merge-39c633d0
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260929-empty-work-merge-39c633d0-empty-work-merge
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-merge-39c633d0
|
||||
- Base commit: 5aabaaf530a453e1459541fb7389d3afad221abf
|
||||
- Owner: codex
|
||||
- Status: Planning
|
||||
|
||||
## Scope
|
||||
|
||||
- Merge completed empty-work preview source f26cba5 (implementation 0167d36) into latest local main, preserving current published teacher shortcuts and window lifecycle changes. Verify the combined tree and fast-forward main. No remote push requested.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User explicitly requests merging this task into main after latest code was pulled. `git fetch origin` confirms local main 5aabaaf is two commits ahead and zero behind origin/main; main is clean. Preserve those local commits and all foreign worktrees.
|
||||
- Concurrent Gate passed in this isolated feature-mode checkout. App worktree creation reports the parent is not a Git repository. Skill isolation created the linked checkout before reporting the canonical integration lock held by 20260923-classroom-integration-8b4f1e62; the clean created checkout was then successfully claimed in feature mode, with matching status/branch/task reservation. The lock is unchanged. No canonical reconciliation is included.
|
||||
- No application restart, deployment, model request, configuration publication, push or cleanup is included. Source task records remain unchanged.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task ID: 20260929-empty-work-merge-39c633d0; mode: feature; branch/worktree/base as recorded above. Ownership and task reservation verified.
|
||||
- Read: AGENTS, read-before-planning, memory-index, active task, planning-gate, project-positioning, current-state, decision-index, relevant teacher ADR, system-overview/data-flow, business-rules, success-criteria, evidence-index, commitments and stale-items; all registered peer Scope, Intent And Constraints and Promotion Candidates sections.
|
||||
- Project goal: three-module creation client with Main-owned local project/runtime and published teacher identities. Current integrated work includes project-isolated teacher chat, Yuxi-configured shortcuts and safe macOS window recreation.
|
||||
- Active peers: shortcut button layout, teacher reply release, help-label and child-language tasks are planning; others are ready for integration. The child-language record has undefined scope, so its coordination context is unknown; current main already has the separately accepted bundled plugin. No concrete dependency on that unfinished task exists. Existing preview task is the source; the user explicitly authorized its no-work exception to the older always-start behavior.
|
||||
- Preserve Main ownership, local project scope, source task history, latest shortcut contracts, unknown/existing-work startup, browser restoration and window lifecycle. Product/code/README are current; older canonical claims and placeholders are read-only. Shared-memory promotion remains for the existing integration owner.
|
||||
- Gate: Passed. Plan: merge the source branch into this owned checkout, resolve only concrete overlaps, run typecheck and relevant unit/UI tests plus production build, independently review, commit the verified merge and fast-forward clean main after rechecking its HEAD.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Prepared a conflict-free merge of f26cba5 into 5aabaaf with source history intact. All changed product/test files exactly match the source commit; README auto-merge retains the latest shortcut configuration and macOS window lifecycle descriptions. Local main update follows the verified candidate commit and a fresh clean-HEAD check.
|
||||
|
||||
## Verification
|
||||
|
||||
- Repository-pinned pnpm 10.33.4; standard typecheck, scoped ESLint, production Renderer/Main/Preload/utility build, 148 unit cases across 11 files and 10 isolated headless browser layout cases passed. Unit coverage includes work absence, busy/unknown paths, host-show, browser adapter and latest window close/reopen behavior. No native trial or paid model call used.
|
||||
- Independent read-only review passed for the current-main compatibility and final staged merge. `git diff --check` and staged whitespace checks passed.
|
||||
- The drift script reports only the unchanged source task record as FOREIGN_DOC because it compares added paths against this merge task's main base. Verified its source/index/worktree Git blob is identical (`e5fb9d1b…`); all shared canonical docs are unchanged. This is imported history required by the user's explicit merge request, not an edit/adoption of another task's work. No registry, lock or drift-script bypass/override was used; the diagnostic remains recorded rather than claimed as a passing script result.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Fast-forward clean local main to the verified merge; leave remote publication and worktree cleanup for explicit user instructions.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- The source task's accepted local-empty-preview rule remains a canonical promotion candidate for the existing integration owner. Source task documentation is retained verbatim, and this merge introduces no additional product decision.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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. The existing temporary app was updated under the later explicit request below; main-branch integration and formal installed-app replacement remain outside scope.
|
||||
|
||||
## 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.
|
||||
|
||||
## Follow-up: Update The Existing Temporary App
|
||||
|
||||
- User explicitly requests updating the temporary application. Same-task ownership resumed and verified; current context remains applicable. Read the current live trial publisher's task record and launch metadata; that chat is idle. No cross-chat messages sent.
|
||||
- Current trial is PID 19053, Main/Renderer ead34078906336492c509ebb616c9cbb1ab9870a with index-BiMeMtQo.js, same trial bundle/profile/Host API 54599. It includes newer welcome UI, teacher dock toggle, help label and timestamps that must be preserved. Formal /Applications/Makelore.app is separate and untouched.
|
||||
- Planning Gate passed. Prepare a generated source export of the exact current live commit, apply only this feature's product/tests patch, verify and build. The foreign source checkout remains read-only. Recheck manifest and actual active work before mutation; capture state, gracefully stop only the verified trial PID, back up its profile, relaunch the same bundle/profile/port in the background, then verify preservation and live empty behavior without model calls.
|
||||
- Updated the live trial using a generated full source export of ead34078906336492c509ebb616c9cbb1ab9870a plus product/test changes from 0167d36. The E2E addition was adapted to the newer fixture while retaining its existing cases. Candidate build, typecheck, scoped lint, 140 unit tests across eight files and all 14 isolated headless layout cases passed. No foreign checkout was edited.
|
||||
- Live source/build: `/var/folders/mx/ltbgwsks5_n8zllj69p_wbcw0000gn/T/makelore-trial-tdq_nuah/empty-work-preview-9c3f72a1/source`. Verified trial PID 19053 was idle, then gracefully quit; copied its full user-data profile before starting PID 23355 with the same bundle/profile and Host API port 54599. New Main module and Renderer asset `index-OKXW93my.js` are loaded. Launch metadata records the base plus feature overlay explicitly, rather than claiming a combined Git commit.
|
||||
- Account restored through the normal auth store `init()` and Main-owned session. Same active project, selected teacher and open teacher dock retained; all 14 existing drafts and all 34 previously recorded conversation/runtime files are byte-identical. One new idle-worker extension metadata file is expected after startup. Help-label UI and 15 rendered timestamps verified. No prompts/model requests were dispatched by the update; no existing project was switched for testing.
|
||||
- Window remains hidden and unfocused as before. After restart the client stays on its default operation-chat tab: the current project has possible work, so reopening preview solely for verification would invoke the existing startup path. Empty-state behavior is verified by isolated Main/unit and headless UI tests, not by prompting or switching a live project. Temporary inspector closed; formal `/Applications/Makelore.app` PID 16701 untouched.
|
||||
- Verification and rollback evidence: `/var/folders/mx/ltbgwsks5_n8zllj69p_wbcw0000gn/T/makelore-trial-tdq_nuah/empty-work-preview-9c3f72a1/verification.json`; profile backup, prior launch manifest, source patch and build/test logs are alongside it. Keep this generated source while the trial uses it. No main merge, push, Yuxi shortcut implementation or formal-app publication was performed.
|
||||
@@ -157,7 +157,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