diff --git a/.project-docs/30-worklog/tasks/20260929-empty-work-preview-9c3f72a1.md b/.project-docs/30-worklog/tasks/20260929-empty-work-preview-9c3f72a1.md new file mode 100644 index 00000000..39775f5e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260929-empty-work-preview-9c3f72a1.md @@ -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. diff --git a/README.md b/README.md index 3e033951..2b8eef79 100644 --- a/README.md +++ b/README.md @@ -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 不属于该产品界面。 - 普通消息接收成功不显示额外的技术提示;准备、错误和排队状态仍正常反馈。发送状态按请求对应的运行结束收敛,保留期间输入的新草稿;恢复历史时优先以持久消息身份关联,避免相同文本串联到其他请求。 diff --git a/electron/coding-runtime/work-presence.ts b/electron/coding-runtime/work-presence.ts new file mode 100644 index 00000000..7a1bd0b9 --- /dev/null +++ b/electron/coding-runtime/work-presence.ts @@ -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 { + 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'; + } +} diff --git a/electron/coding-runtime/work-preview.ts b/electron/coding-runtime/work-preview.ts index e12c2f45..a674e1b1 100644 --- a/electron/coding-runtime/work-preview.ts +++ b/electron/coding-runtime/work-preview.ts @@ -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; ensureActive(project: Project): Promise; responds?(url: string): Promise; + inspectWork?(projectPath: string): Promise; }) {} 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); diff --git a/shared/coding-work-preview.ts b/shared/coding-work-preview.ts index fba6cb3c..c7fd0e26 100644 --- a/shared/coding-work-preview.ts +++ b/shared/coding-work-preview.ts @@ -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; diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index 25e1feb3..86fcd0c3 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -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({ /> - {error ? ( + {error && !workEmpty ? (

{error}

@@ -538,17 +545,17 @@ export function AgentBrowserPanel({