From ae1da25a125d23ca7823d3ebc4ac570fd662d020 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:08:34 +0800 Subject: [PATCH 1/9] feat(agents): add skippable first-use software recommendations --- .../backend/external-agent-catalog-runtime.md | 6 + .trellis/spec/backend/first-use-guide.md | 79 ++++++ .trellis/spec/backend/index.md | 3 + .trellis/spec/frontend/agent-directory.md | 4 +- .trellis/spec/frontend/first-use-guide.md | 100 +++++++ .trellis/spec/frontend/index.md | 1 + .trellis/spec/frontend/user-facing-copy.md | 8 +- .../check.jsonl | 5 + .../09-15-first-use-software-guide/design.md | 37 +++ .../implement.jsonl | 6 + .../implement.md | 62 +++++ .../09-15-first-use-software-guide/prd.md | 35 +++ .../09-15-first-use-software-guide/task.json | 26 ++ config/playwright.config.ts | 1 + .../supported-platform-structure-assets.json | 4 +- .../legacy-application-commands.toml | 2 + src-tauri/src/commands/agent_catalog.rs | 33 ++- src-tauri/src/commands/settings.rs | 32 +++ src-tauri/src/lib.rs | 24 +- src-tauri/src/settings.rs | 10 + src-tauri/src/settings/first_use_guide.rs | 115 ++++++++ src/pages/agents/AgentDirectory.tsx | 14 +- src/pages/agents/FirstUseGuide.css | 106 ++++++++ src/pages/agents/FirstUseGuide.tsx | 162 ++++++++++++ src/pages/agents/Page.tsx | 65 ++++- src/pages/agents/firstUseRecommendations.ts | 43 +++ src/shared/features/first-use-guide.ts | 6 + src/shared/features/ports.ts | 3 + src/shared/features/queries.ts | 14 + src/shared/platform/browser/features.ts | 2 + .../platform/tauri/feature-ports/simple.ts | 13 + tests/browser/first-use-guide.spec.ts | 148 +++++++++++ tests/browser/navigation-performance.spec.ts | 32 +++ tests/browser/support/features.ts | 30 ++- tests/renderer/pages/agents/Page.test.tsx | 247 +++++++++++++++++- .../platform/firstUseGuidePort.test.ts | 46 ++++ .../platform/tauriAclContract.test.ts | 4 +- 37 files changed, 1469 insertions(+), 59 deletions(-) create mode 100644 .trellis/spec/backend/first-use-guide.md create mode 100644 .trellis/spec/frontend/first-use-guide.md create mode 100644 .trellis/tasks/09-15-first-use-software-guide/check.jsonl create mode 100644 .trellis/tasks/09-15-first-use-software-guide/design.md create mode 100644 .trellis/tasks/09-15-first-use-software-guide/implement.jsonl create mode 100644 .trellis/tasks/09-15-first-use-software-guide/implement.md create mode 100644 .trellis/tasks/09-15-first-use-software-guide/prd.md create mode 100644 .trellis/tasks/09-15-first-use-software-guide/task.json create mode 100644 src-tauri/src/settings/first_use_guide.rs create mode 100644 src/pages/agents/FirstUseGuide.css create mode 100644 src/pages/agents/FirstUseGuide.tsx create mode 100644 src/pages/agents/firstUseRecommendations.ts create mode 100644 src/shared/features/first-use-guide.ts create mode 100644 tests/browser/first-use-guide.spec.ts create mode 100644 tests/renderer/platform/firstUseGuidePort.test.ts diff --git a/.trellis/spec/backend/external-agent-catalog-runtime.md b/.trellis/spec/backend/external-agent-catalog-runtime.md index 9c6bb4bbe..4794f4263 100644 --- a/.trellis/spec/backend/external-agent-catalog-runtime.md +++ b/.trellis/spec/backend/external-agent-catalog-runtime.md @@ -85,6 +85,12 @@ Parser drift against this table rejects the whole catalog. ### Static catalog +- Directory descriptions state supported capabilities only, without a list of + unsupported features or runtime uncertainty. Keep exact capability modes and + action-time errors/unknown states unchanged; positive summary prose does not + grant new authority. TRAE's model handoff may be described positively with + its vendor-owned location. + - The catalog is deterministic and performs no filesystem, process, network, registry, database, or credential read. - IDs, order, display names, variant IDs, official links, capability order and diff --git a/.trellis/spec/backend/first-use-guide.md b/.trellis/spec/backend/first-use-guide.md new file mode 100644 index 000000000..f5b3882d3 --- /dev/null +++ b/.trellis/spec/backend/first-use-guide.md @@ -0,0 +1,79 @@ +# Device-local First-use Guide + +## 1. Scope / Trigger + +Read before changing first-install eligibility, guide persistence or its Tauri +commands. `settings/first_use_guide.rs` owns the state; `lib.rs` supplies startup +data-directory evidence. [Renderer First-use Guide](../frontend/first-use-guide.md) +owns presentation and recommendations. This is not software installation, +authentication, model configuration, telemetry or a database migration. + +## 2. Signatures + +```text +settings.json: firstUseGuideState?: "pending" | "dismissed" +get_first_use_guide_state() -> "pending" | "dismissed" +dismiss_first_use_guide() -> Result<"dismissed", String> +``` + +Commands accept no paths, settings snapshots, software IDs or purpose choices. +Register both in `generate_handler!` and the active application permission set. +The Tauri SettingsPort parses unknown responses; a `pending` dismissal response +is an error rather than successful completion. + +## 3. Contracts + +- Initialize pending before database creation/seeding, only when the database, + legacy `config.json` and device `settings.json` are confirmed absent. An + existence-check error is not absence. Existing settings, even malformed, + must not be overwritten just to initialize this guide. +- No marker on an existing installation means dismissed. Do not infer first + install from an empty providers table, a missing renderer localStorage key, + application version or the absence of detected third-party software. +- Existing pending survives restart; dismissed and legacy + `firstRunNoticeConfirmed: true` suppress the guide. Closing the app without + finishing or skipping does not manufacture user acknowledgement. +- Dismissal uses the existing settings write lock and persistence owner to + merge only `firstUseGuideState: dismissed` and + `firstRunNoticeConfirmed: true`. Persist before changing the in-memory value. +- Ordinary `save_settings` preserves the native-owned guide marker and a true + legacy acknowledgement against stale renderer snapshots. Purpose choice is + not persisted or transmitted. +- The device file, not the synced database or renderer cache, is the restart + authority. Removing all local application data creates a new installation + context; replacing the app binary while retaining data does not. + +## 4. Validation & Error Matrix + +| Condition | Result | +| --- | --- | +| Confirmed new local data, no acknowledgement | Persist pending before DB initialization. | +| Existing DB, JSON or settings and no marker | Return dismissed without onboarding initialization writes. | +| Pending with DB now present | Continue pending. | +| Dismissed or legacy confirmed | Do not reopen. | +| Initialization persistence failure | Log safely; do not claim pending was saved. | +| Dismissal persistence failure | Return failure and retain prior in-memory state. | +| Stale ordinary settings save | Preserve guide completion. | +| Unknown wire state or pending dismissal acknowledgement | Reject at renderer adapter. | + +## 5. Good / Base / Bad Cases + +Good: a completed guide remains dismissed after a settings panel saves an old +snapshot. Base: an interrupted first launch resumes its pending guide. Bad: +checking providers after built-in providers were seeded or submitting the +entire renderer settings object merely to dismiss onboarding. + +## 6. Tests Required + +`settings/first_use_guide.rs` tests cover eligibility, old settings, legacy +acknowledgement, closed serialization and pending restart. The commands/settings +merge test protects completion from stale saves. Renderer port and ACL tests +cover exact no-argument calls, response rejection and registration/permission +closure. Run Rust format/check/Clippy/tests and renderer type/lint/unit gates. +Browser persistence fixtures are mock evidence, not fresh-install HIL evidence. + +## 7. Wrong vs Correct + +Wrong: `save_settings({ ...cachedSettings, firstRunNoticeConfirmed: true })`. +Correct: `dismiss_first_use_guide()` merges the two native-owned fields against +the latest locked settings and acknowledges only after persistence. diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index e457d3d74..19a5546a1 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -50,6 +50,9 @@ secret handling, native source checks, and residual-risk reporting. ## Product, configuration, and runtime security +[Device-local First-use Guide](./first-use-guide.md) owns new-install eligibility, +settings persistence, narrow commands and protection against stale settings saves. + [Agent Health Observation](./health.md) owns the on-demand local status snapshot and its read-only installation/configuration/auth/proxy evidence. diff --git a/.trellis/spec/frontend/agent-directory.md b/.trellis/spec/frontend/agent-directory.md index 7bbd21b89..1671bb80d 100644 --- a/.trellis/spec/frontend/agent-directory.md +++ b/.trellis/spec/frontend/agent-directory.md @@ -18,7 +18,9 @@ Primary owners: Native authority is split between [Agent Catalog and Runtime](../backend/external-agent-catalog-runtime.md) and [Agent Lifecycle](../backend/external-agent-lifecycle.md). Auth UI has its own -owner: [Renderer Agent Auth](./agent-auth.md). +owner: [Renderer Agent Auth](./agent-auth.md). First-install eligibility and the +optional recommendation page belong to [First-use Guide](./first-use-guide.md). +The ordinary directory scan waits while that guide is active. ## 2. Signatures diff --git a/.trellis/spec/frontend/first-use-guide.md b/.trellis/spec/frontend/first-use-guide.md new file mode 100644 index 000000000..c7a8ab5f3 --- /dev/null +++ b/.trellis/spec/frontend/first-use-guide.md @@ -0,0 +1,100 @@ +# First-use Software Recommendations + +## 1. Scope / Trigger + +Read before changing first-use presentation on `/agents`. `FirstUseGuide.tsx` +owns the two-step UI; `firstUseRecommendations.ts` owns the route-local purpose +association. [Native First-use State](../backend/first-use-guide.md) owns device +eligibility and persistence. [Agent Directory](./agent-directory.md) still owns +catalog, installation, configuration and software order. + +## 2. Signatures + +```ts +type GuidePurpose = "office" | "coding" | "both"; +type FirstUseGuideState = "pending" | "dismissed"; +SettingsPort.getFirstUseGuideState(): Promise; +SettingsPort.dismissFirstUseGuide(): Promise<"dismissed">; +``` + +Use `featureKeys.firstUseGuide` through the shared query owner. The native +adapter parses unknown responses; browser fallback returns dismissed and +rejects native-only dismissal instead of pretending to save. + +## 3. Contracts + +- Only the directory branch may show onboarding. A valid explicit `target` + remains authoritative. Query activity follows persistent route visibility. +- Wait for catalog and guide-state settlement before presenting the directory + or guide and acknowledging frontend-ready. Do not use RAF/document visibility + for native startup readiness. A failed guide read opens the ordinary directory, + not an invented fresh-install state. +- Lazy-load the one-time guide and its CSS only for a pending user. The committed + guide component owns frontend-ready while its chunk is loading; a Suspense + placeholder must not reveal the native window. Catalog error/empty states keep + their own ready acknowledgement instead of waiting for an unmounted guide. + Keep the existing initial-JavaScript budget; do not raise it to admit onboarding. + Audit the static dependency closure as well as page chunks: a hook first used + in a lazy page can still grow a bootstrap-shared vendor chunk. Keep this narrow + write consistent with the existing guarded local-operation pattern. +- The question is one sentence, with office, coding and combined-use options. + Both steps expose skip. Selection shows a small set of recommendations, with + back/reselect and a full-directory action; it is not an installation wizard. +- Office recommends QoderWork CN, TRAE Work CN and WorkBuddy; coding recommends + Codex, Claude Code and OpenCode; combined use recommends WorkBuddy and Codex. + These are purpose associations, not capability, platform or quality rankings. + Intersect IDs with the parsed catalog and preserve its names/order. Do not + create a fallback catalog or infer installation/action permissions. +- Purpose is component-local. Choosing it performs no native write, auth, + install, model change or telemetry. Directory scanning starts only after the + first-use check settles with no guide or dismissal succeeds. +- Completion and skip use the same narrow native dismissal. Keep the current + step on failure with a safe retry message; never display raw native errors. + A synchronous admission guard and disabled controls prevent duplicate writes. +- Cache only successful persisted dismissal. Hidden/unmounted completion may + update the shared state but must not steal focus, reopen portals or scan. + Focus the guide heading on step changes and the directory heading on visible + completion. Reuse Button/PressableButton, brand assets and semantic CSS tokens. + +## 4. Validation & Error Matrix + +| Condition | UI behavior | +| --- | --- | +| Pending and valid catalog on directory | Show purpose question, not a directory flash. | +| Dismissed / old installation | Ordinary directory. | +| Explicit software target | Existing configuration view, no guide interception. | +| Guide read fails | Ordinary directory; no acknowledgement write. | +| Save pending | Keep guide; disable duplicate actions and choices. | +| Save fails | Keep current recommendations; allow retry. | +| Save succeeds | Full directory; no repeat with a fresh query client. | +| Route hidden while save finishes | Update cache without focus or scan work. | + +## 5. Good / Base / Bad Cases + +Good: recommend current catalog names, then let the user browse all software. +Base: restart an unfinished guide at the purpose question. Bad: turn a purpose +selection into an automatic installation, persist a marketing profile or force +existing installations through onboarding after an upgrade. + +## 6. Tests Required + +`pages/agents/Page.test.tsx` covers all three sets, reselection, both skip paths, +completion, new query-client restart, delayed/failed persistence, duplicate +clicks, unknown startup reads, target links and hidden completion. Native-port +tests reject unknown states and pending write acknowledgements. +`browser/first-use-guide.spec.ts` covers keyboard focus, both themes, +large-small-large viewport changes, real click reachability, persistence and +positive catalog copy in Chromium/WebKit. Run the existing directory/browser +regressions and production boot gate. WebKit keyboard tests use its Option-Tab +all-controls navigation, without changing the host keyboard-access setting. +Browser fixtures do not prove native +first-install or Windows/macOS installer behavior. +The production navigation smoke test also verifies that a fresh user loads the +guide chunk and a post-skip reload does not request it. + +## 7. Wrong vs Correct + +Wrong: `if (!localStorage.getItem("welcome")) showGuide()` or hiding unsupported +actions by rewriting their runtime status. Correct: native first-use state owns +eligibility; only catalog summary prose is positive-only, while actual operation +errors, unknown states and security confirmations stay visible. diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md index 8964350d0..27c52bc12 100644 --- a/.trellis/spec/frontend/index.md +++ b/.trellis/spec/frontend/index.md @@ -37,6 +37,7 @@ focused feature owner. Apply [Component Guidelines](./component-guidelines.md), | [Window Shell](./window-shell.md) | Chrome, native overlay boundary, selection and shared interaction. | | [Change Plan Workspaces](./change-plan-workspaces.md) | Preview/apply, source switching, job observation and reconciliation. | | [Agent Directory](./agent-directory.md) | Catalog, scan/readiness, cards, installation and capabilities. | +| [First-use Guide](./first-use-guide.md) | Skippable purpose recommendations, native eligibility and completion. | | [Agent Health](./health.md) | Local check snapshots, stale facts, serial refresh and existing repair routes. | | [External Agent Auth](./agent-auth.md) | Native auth observations, session ownership and safe handoff. | | [Managed Auth](./managed-auth.md) | Accounts/connections/request sources, login and impact confirmation. | diff --git a/.trellis/spec/frontend/user-facing-copy.md b/.trellis/spec/frontend/user-facing-copy.md index 170f7ffe3..ca9852730 100644 --- a/.trellis/spec/frontend/user-facing-copy.md +++ b/.trellis/spec/frontend/user-facing-copy.md @@ -95,7 +95,7 @@ not user copy. - Windows vendor-wizard success copy states that the installer opened and the user should finish it, then refresh. It must not say the product is installed. - OpenCode Windows x64 may be offered as a current-user official installer. - ARM64 remains unavailable. Catalog description states Skills/MCP/Hooks + ARM64 remains unavailable. Catalog description states Skills/model/MCP support only; do not add 「本机识别和启动暂无法确认」. Claude Code now uses its CLI-only lifecycle and must not promise Claude Desktop support. Destination labels may use the display name and must not be treated as the scanned folder @@ -126,6 +126,12 @@ Examples: ### Concise secondary surfaces +Software-directory summaries describe supported capabilities, not lists of +unsupported features. Keep capability matrices and real failure/unknown/safety +messages at their operation points. First-use recommendations use one question, +three purpose choices and an explicit skip action; see +[First-use Guide](./first-use-guide.md). + The object name, meaningful state and actions are the default hierarchy across all eight routes and their details/dialogs. A heading does not require a subtitle. Add supporting text only for a non-obvious choice, consequence, diff --git a/.trellis/tasks/09-15-first-use-software-guide/check.jsonl b/.trellis/tasks/09-15-first-use-software-guide/check.jsonl new file mode 100644 index 000000000..0a07f23e0 --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/frontend/agent-directory.md","reason":"原生目录和浏览器回归"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"类型、测试与真实证据边界"} +{"file":".trellis/spec/backend/modular-boundaries.md","reason":"窄命令注册、权限和 owner 复用"} +{"file":".trellis/spec/frontend/first-use-guide.md","reason":"两步引导、推荐、键盘与重启回归"} +{"file":".trellis/spec/backend/first-use-guide.md","reason":"兼容旧安装和完成状态防覆盖"} diff --git a/.trellis/tasks/09-15-first-use-software-guide/design.md b/.trellis/tasks/09-15-first-use-software-guide/design.md new file mode 100644 index 000000000..ae6e63c73 --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/design.md @@ -0,0 +1,37 @@ +# Design + +## Boundaries + +在原生目录修改简介源数据,不在 Renderer 截断或替换负面短语。推荐规则只记录当前封闭 Agent ID 的用途关联;名称、能力和安装状态仍由原生目录与既有生命周期模块拥有。 + +## First-use authority + +复用本机 `settings.json` 的锁和持久化 owner,增加可选 `firstUseGuideState`:`pending | dismissed`。缺省读取为 dismissed(兼容旧安装)。首次初始化只有数据库、旧 config.json 和本机设置均确认不存在时记录 pending;在创建数据库前持久化,避免 seed 后丢失首次身份。已有 pending 保留;旧欢迎确认始终抑制引导。 + +两个无参数窄命令读取/结束引导;结束只合并引导状态和旧欢迎确认字段,不提交整份前端设置快照。普通 `save_settings` 在既有锁内保留新字段。用途选择只留在页面内存,不写遥测或外部软件。 + +## Renderer + +在 Agents 路由的目录分支内插入两步引导,使用现有 Button、品牌图标和主题 token,不引入依赖或新路由。首次状态为异步查询,尚未明确时不闪出引导;失败不阻塞主目录。显式 target 不拦截。未完成引导时不启动目录扫描。 + +引导和它的 CSS 使用既有 React lazy/Suspense 机制按需加载,旧用户不加载一次性引导 chunk。新用户由实际提交的引导组件报告 frontend-ready,不能让加载占位提前显示原生窗口;目录失败/空状态独立报告 ready。生产包体积遵循现有 665600 字节上限。 + +用途选择:办公优先 QoderWork CN、TRAE Work CN、WorkBuddy;编程优先 Codex、Claude Code、OpenCode;混合展示 WorkBuddy 与 Codex,覆盖两类入门路径。结果严格与解析后的目录取交集,沿用目录顺序,不生成安装权限。 + +完成与跳过都等待原生持久化成功后退出,失败保留当前步骤并给出简短重试提示。推荐页提供重新选择和查看全部软件,避免未安装用户被送入不可配置页面。操作使用同步防重入锁;隐藏/卸载后不写页面状态。 + +## Research + +2026-09-15 核对官方产品定位(用途关联,不作为 FyAgent 能力/安装权限来源): + +- https://docs.qoder.com/ :QoderWork 桌面工作助手,文件整理、数据处理、文档生成。 +- https://www.trae.cn/ :办公、文档、数据与代码场景。 +- https://www.workbuddy.ai/ :日常办公 AI 工作台。 +- https://openai.com/codex/ :编程任务。 +- https://code.claude.com/docs/en/quickstart :代码理解、修改与测试;本项目只承诺已接入 CLI。 +- https://opencode.ai/ :开源编程 Agent,桌面/终端使用。 +- https://support.apple.com/guide/safari/keyboard-shortcuts-and-gestures-cpsh003/mac :WebKit/Safari 的 Tab 导航受本机 Keyboard Navigation 设置影响;Option-Tab 遍历可点击控件。最小原生按钮复现已证实当前测试主机行为,测试不修改用户系统设置。 + +## Compatibility and rollback + +不改目录 wire 版本、ID、capability、安装按钮策略、数据库 schema 或依赖。新增设置字段为可选;旧版本忽略它。回滚产品提交可移除 UI 与窄命令;保留字段不会影响原配置。不得以本次浏览器 mock 结果声称完成真机首次安装验收。 diff --git a/.trellis/tasks/09-15-first-use-software-guide/implement.jsonl b/.trellis/tasks/09-15-first-use-software-guide/implement.jsonl new file mode 100644 index 000000000..e6a20face --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/frontend/agent-directory.md","reason":"目录、原生 catalog 与生命周期边界"} +{"file":".trellis/spec/frontend/state-management.md","reason":"查询状态、启动与隐藏路由约束"} +{"file":".trellis/spec/frontend/user-facing-copy.md","reason":"轻量文案和真实操作状态"} +{"file":".trellis/spec/backend/modular-boundaries.md","reason":"settings owner 与命令/权限闭环"} +{"file":".trellis/spec/frontend/first-use-guide.md","reason":"首次引导、推荐、跳过与隐藏路由边界"} +{"file":".trellis/spec/backend/first-use-guide.md","reason":"本机首次身份与窄状态写入契约"} diff --git a/.trellis/tasks/09-15-first-use-software-guide/implement.md b/.trellis/tasks/09-15-first-use-software-guide/implement.md new file mode 100644 index 000000000..c47b119fa --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/implement.md @@ -0,0 +1,62 @@ +# Implementation and validation + +## Completed plan + +- [x] 原生目录正向简介;首次状态与初始化;窄命令、权限与普通设置防覆盖。 +- [x] Renderer 类型、解析、端口与查询;用途选择与推荐视图;按需加载、启动与目标链接隔离。 +- [x] 原生兼容、序列化与设置合并测试;前端交互、隐藏/异步失败、浏览器与生产回归。 +- [x] 评审产品边界、持久化安全、键盘/主题/布局和首次加载预算,修复发现的问题。 +- [x] 更新集中 SPEC 与发现入口,验证任务上下文。 +- [x] 完成归档前完整门禁并记录真实验收结果。 + +## Final acceptance + +| 检查 | 最终结果 | +| --- | --- | +| 聚焦页面、端口及 ACL 单测 | 47/47 通过 | +| 全量前端/契约单测 | 188 个文件通过;1678 通过、1 项既有条件跳过 | +| 桌面 mock | 7/7 通过 | +| 桌面视觉前置检查 | 通过 | +| Rust 格式、check、Clippy | 全部通过 | +| 原生全量测试 | 3570 通过、0 失败、6 项既有条件忽略 | +| Chromium/WebKit 目录与引导回归 | 42/42 通过;四档 Chromium 尺寸和 WebKit | +| 生产构建启动回归 | 3/3 通过;八个主路由、首次懒加载/跳过及动效时间单位 | +| 生产入口预算 | JS 665004 字节,低于 665600 上限;CSS 46664 字节;八路由分包通过 | +| 归档前完整门禁 | `mise run check:prearchive --exclude-active-task .trellis/tasks/09-15-first-use-software-guide` 通过 | +| 任务上下文 | implement 6 条、check 5 条校验通过 | + +完整门禁、浏览器和生产构建验证串行执行。Release 契约子集另有 619 通过、1 项既有条件跳过,native-fetch 4/4 通过;这些与全量测试有重叠,不重复累计为新增测试量。 + +## Review and repairs + +### Product and persistence + +首页七个软件只描述已支持能力,保留真实安装、未知状态和安全提示。两步引导使用一句用途提问、三个选择及始终可见的跳过入口;推荐不触发安装、登录、模型写入或遥测。 + +首次身份在创建数据库前判定并保存;旧数据或设置文件不是全新安装。结束引导只合并本机引导状态,普通设置旧快照不能重新打开引导。完成和失败在隐藏/卸载场景下不会修改页面状态或抢焦点;隐藏期间失败会在返回时恢复重试界面。 + +### Test findings + +补齐 Renderer 与 Rust 的两个新增接口集合断言:113 个 Renderer invoke、371 个原生已注册命令,保留注册/权限全集相等检查。既有安装测试改为等待独立 inventory 查询后的操作按钮出现,不改变产品权限或状态投影。 + +并发运行浏览器与整仓检查时,两个既有扫描测试触发 5 秒 watchdog;分开串行复验后通过,未放宽时限。WebKit 的普通 Tab 在当前宿主跳过按钮,独立原生按钮页已复现;依据 Apple 官方说明,测试使用 Option-Tab 遍历控件,不改用户系统设置。 + +### Startup and bundle budget + +生产构建曾报初始 JS 666965 字节,超过 665600 上限。复查初始依赖闭包发现:仅把引导从路由里再拆包不够,仓库首次使用的 `useMutation` 增加了公共 query 包体积。沿用现有 ref 准入锁与局部异步状态模式后,最终初始 JS 为 665004 字节,保持预算不变。 + +引导及其 CSS 仅在 pending 时加载。真实提交的引导组件报告 frontend-ready,Suspense 占位不提前显示原生窗口;目录失败和空状态独立就绪。生产测试验证新用户加载独立引导 chunk,跳过后刷新不再请求它。 + +修正了渲染期间读取可变 ref 的初始化写法,保持 React hooks 检查通过;将悬停边框改为已有 `--fy-border-strong`,检查确认引导 CSS 所有变量均有声明。 + +### Platform and SPEC + +审核 `lib.rs`、`settings.rs` 的最终 diff,未改变平台条件、当前用户路径或安全边界;仅更新这两个既有平台审查候选文件的最终指纹,没有批量刷新或绕过扫描器。没有新增依赖、数据库 schema 或外部服务。 + +新增前后端 `first-use-guide.md` 两份 owner SPEC,更新索引、目录与正向文案约定。首次判定、写入防覆盖、隐藏页行为和公共分包预算的经验已落入 SPEC。 + +## Evidence boundary and closeout + +当前宿主为 macOS arm64;没有执行 macOS/Windows 物理全新安装验收。浏览器持久化使用原生端口夹具,不等同于真实安装器或外部软件验收。没有安装外部 AI 软件,也没有生成发行安装包。 + +工作提交之后使用仓库任务工具归档,再记录会话;提交与完成状态以 `task.json` 和会话日志为准。归档后执行不带 active-task 排除的 `mise run check:contracts`,不推送远端。 diff --git a/.trellis/tasks/09-15-first-use-software-guide/prd.md b/.trellis/tasks/09-15-first-use-software-guide/prd.md new file mode 100644 index 000000000..896c83b46 --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/prd.md @@ -0,0 +1,35 @@ +# AI 软件正向文案与首次使用推荐引导 + +## Goal + +让首次使用 FyAgent 的用户通过用途选择找到适合起步的 AI 软件;熟悉软件的用户可直接跳过。首页软件简介只介绍已支持的能力。 + +## Background + +- `src/pages/agents/AgentDirectory.tsx` 直接展示原生目录的 `description`。 +- `src-tauri/src/commands/agent_catalog.rs` 的七个软件简介包含不支持能力或历史不确定性描述。 +- `src-tauri/src/lib.rs` 的首次运行快照目前只打印日志;旧 `firstRunNoticeConfirmed` 字段没有当前 UI 消费者。 +- 首页会自动扫描软件;推荐本身不应触发安装、登录或配置写入。 + +## Requirements + +- R1:首页简介只描述已支持的能力,不罗列不支持的能力;操作现场的失败、未知状态和安全提示保持不变。 +- R2:全新本地安装首次进入软件目录时展示一句提问:你主要想用 AI 做什么?选项为日常办公、编程开发、两者都用。 +- R3:选择后展示当前目录中少量相关软件及简短用途说明,可重新选择,也可进入完整软件目录。 +- R4:两个步骤均可跳过。完成或跳过后持久化,重启、刷新、切换页面不再展示;尚未完成就退出时可继续引导。 +- R5:已有数据库、旧配置或本机设置的升级用户不自动进入引导;设置读取不明不视作全新用户。显式软件目标链接优先,不被引导覆盖。 +- R6:推荐不安装、不登录、不改模型、不上传用途偏好,不锁定用户后续的软件选择。 +- R7:创建并执行 Trellis 任务,验证实现,归档之前更新相关 SPEC。 + +## Acceptance Criteria + +- AC1 / R1:七个原生简介均为正向能力描述;能力矩阵和运行状态不变。 +- AC2 / R2–R3:三种用途分别得到正确的软件集合,名称来自已解析目录,无重复或不存在的软件,可返回修改用途。 +- AC3 / R4:首次展示、跳过、完成、失败重试、重启不重现均有自动化覆盖。 +- AC4 / R5:已有安装、已完成旧欢迎、异常状态、深链接与隐藏路由行为有回归覆盖。 +- AC5 / R6:引导操作只写自身完成状态;普通设置旧快照不会恢复已结束引导。 +- AC6 / R7:类型、lint、单测、原生针对性测试、浏览器与生产构建证据写入任务;SPEC 更新后通过归档前门禁并归档。 + +## Out of Scope + +新增软件、安装/授权流程改造、付费或模型选择向导、推荐服务、遥测、常驻后台、重做软件目录排序,以及修改软件配置页真实限制提示。 diff --git a/.trellis/tasks/09-15-first-use-software-guide/task.json b/.trellis/tasks/09-15-first-use-software-guide/task.json new file mode 100644 index 000000000..a99e95800 --- /dev/null +++ b/.trellis/tasks/09-15-first-use-software-guide/task.json @@ -0,0 +1,26 @@ +{ + "id": "first-use-software-guide", + "name": "first-use-software-guide", + "title": "AI 软件正向文案与首次使用推荐引导", + "description": "调整首页软件能力描述,新增可跳过且仅首次展示的用途推荐引导,验证并更新 SPEC 后归档。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pythonrust", + "assignee": "pythonrust", + "createdAt": "2026-09-15", + "completedAt": null, + "branch": "dev/laiyongjie", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/config/playwright.config.ts b/config/playwright.config.ts index 5d6de250f..b1c4ac0aa 100644 --- a/config/playwright.config.ts +++ b/config/playwright.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ "layout-integrity.spec.ts", "auth.spec.ts", "health.spec.ts", + "first-use-guide.spec.ts", "xai-subscription.spec.ts", "presentation-choreography.spec.ts", ], diff --git a/scripts/tasks/supported-platform-structure-assets.json b/scripts/tasks/supported-platform-structure-assets.json index 77743103c..9562d3c46 100644 --- a/scripts/tasks/supported-platform-structure-assets.json +++ b/scripts/tasks/supported-platform-structure-assets.json @@ -163,7 +163,7 @@ ], [ "src-tauri/src/lib.rs", - "b2d22ce4e03d7ab5f80bd501b04d9740dc0dfe4a8482dd63e59107eff4ffe2b8" + "d922fa93a7240fd21fc8ae98c02faebaa128559da8a88a4f942e59092896ef6e" ], [ "src-tauri/src/lightweight.rs", @@ -323,7 +323,7 @@ ], [ "src-tauri/src/settings.rs", - "5ee6e10af746639c69332cb0b4b9a47473e669e04b037d137742669777c765b8" + "02ddcaa503d348a988a17cb9aeee323f2ed6b706aad7f292485b8dfa3261758d" ], [ "src-tauri/src/tray.rs", diff --git a/src-tauri/permissions/legacy-application-commands.toml b/src-tauri/permissions/legacy-application-commands.toml index 9162a0a7e..ad8bfd9e9 100644 --- a/src-tauri/permissions/legacy-application-commands.toml +++ b/src-tauri/permissions/legacy-application-commands.toml @@ -158,6 +158,8 @@ commands.allow = [ "get_runtime_privilege_status", "get_session_messages", "get_settings", + "get_first_use_guide_state", + "dismiss_first_use_guide", "get_skill_backups", "get_skill_repos", "get_skills", diff --git a/src-tauri/src/commands/agent_catalog.rs b/src-tauri/src/commands/agent_catalog.rs index e01d4f3ae..86f9f5b51 100644 --- a/src-tauri/src/commands/agent_catalog.rs +++ b/src-tauri/src/commands/agent_catalog.rs @@ -644,7 +644,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::QoderWork, variant_id: AgentVariantId::QoderWorkCn, display_name: "QoderWork CN", - description: "支持 Skills 同步与 MCP 直接分配;不支持第三方模型配置。", + description: "支持 Skills 同步与 MCP 直接分配。", official_links: &QODERWORK_OFFICIAL_LINKS, capabilities: &QODERWORK_CAPABILITIES, }, @@ -652,8 +652,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::TraeWork, variant_id: AgentVariantId::TraeWorkCn, display_name: "TRAE Work CN", - description: - "支持 Skills 同步与 MCP 直接分配;自定义模型需在 TRAE Work CN 中添加;不支持 Hooks。", + description: "支持 Skills 同步与 MCP 直接分配;自定义模型可在 TRAE Work CN 中添加。", official_links: &TRAE_WORK_OFFICIAL_LINKS, capabilities: &TRAE_WORK_CAPABILITIES, }, @@ -661,7 +660,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::WorkBuddy, variant_id: AgentVariantId::WorkBuddy, display_name: "WorkBuddy", - description: "支持 Skills 同步、模型配置与 MCP 直接分配;不支持 Hooks。", + description: "支持 Skills 同步、模型配置与 MCP 直接分配。", official_links: &WORKBUDDY_OFFICIAL_LINKS, capabilities: &WORKBUDDY_CAPABILITIES, }, @@ -669,7 +668,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::GrokBuild, variant_id: AgentVariantId::GrokBuild, display_name: "Grok Build", - description: "支持 Skills 同步、模型配置与 MCP 直接分配。本机识别和启动暂无法确认。", + description: "支持 Skills 同步、模型配置与 MCP 直接分配。", official_links: &GROKBUILD_OFFICIAL_LINKS, capabilities: &GROKBUILD_CAPABILITIES, }, @@ -677,7 +676,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::Codex, variant_id: AgentVariantId::Codex, display_name: "Codex", - description: "支持桌面安装、Skills、模型配置与 MCP;不支持 Hooks。", + description: "支持桌面安装、Skills、模型配置与 MCP。", official_links: &[], capabilities: &CODEX_CAPABILITIES, }, @@ -685,8 +684,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::ClaudeCode, variant_id: AgentVariantId::ClaudeCode, display_name: "Claude Code", - description: - "支持 Claude Code CLI 安装、官方登录、Skills、模型配置与 MCP;不安装 Claude Desktop。", + description: "支持 Claude Code CLI 安装、官方登录、Skills、模型配置与 MCP。", official_links: &CLAUDE_OFFICIAL_LINKS, capabilities: &CLAUDE_CODE_CAPABILITIES, }, @@ -694,7 +692,7 @@ const AGENT_CATALOG: [AgentCatalogEntry; 7] = [ id: AgentCatalogId::OpenCode, variant_id: AgentVariantId::OpenCode, display_name: "OpenCode", - description: "支持 Skills、模型配置与 MCP;不支持 Hooks。", + description: "支持 Skills、模型配置与 MCP。", official_links: &OPENCODE_OFFICIAL_LINKS, capabilities: &OPENCODE_CAPABILITIES, }, @@ -996,7 +994,7 @@ mod tests { assert!(!trae.description.contains("本机识别和启动暂无法确认")); assert!(!workbuddy.description.contains("本机识别和启动暂无法确认")); assert!(!opencode.description.contains("本机识别和启动暂无法确认")); - assert!(grok.description.contains("本机识别和启动暂无法确认")); + assert!(!grok.description.contains("本机识别和启动暂无法确认")); assert_eq!(grok.display_name, "Grok Build"); assert_eq!(grok.official_links[0].label, "打开 Grok Build 官方页面"); assert_eq!(grok.official_links[0].url, "https://x.ai/grok"); @@ -1005,7 +1003,7 @@ mod tests { assert_eq!(trae.display_name, "TRAE Work CN"); assert_eq!(trae.official_links[0].label, "打开 TRAE Work CN 官方页面"); assert_eq!(trae.official_links[0].url, "https://www.trae.cn/sem-work"); - assert!(qoder.description.contains("不支持第三方模型配置")); + assert!(!qoder.description.contains("第三方模型配置")); assert!(qoder.description.contains("MCP 直接分配")); assert!( !qoder.description.contains("Hooks") && !qoder.description.contains("hooks"), @@ -1014,8 +1012,15 @@ mod tests { assert!(trae.description.contains("MCP 直接分配")); assert!(trae .description - .contains("自定义模型需在 TRAE Work CN 中添加")); + .contains("自定义模型可在 TRAE Work CN 中添加")); for entry in &catalog.agents { + for retired in ["不支持", "不安装", "暂无法确认"] { + assert!( + !entry.description.contains(retired), + "{}: {retired}", + entry.display_name + ); + } assert!( !entry.description.contains("可通过 FyAgent"), "{} must not use 可通过 FyAgent", @@ -1298,7 +1303,9 @@ mod tests { assert!(registered.contains("bind_xai_managed_provider")); assert!(registered.contains("get_agent_health")); - assert_eq!(registered.len(), 369, "review intentional handler changes"); + assert!(registered.contains("get_first_use_guide_state")); + assert!(registered.contains("dismiss_first_use_guide")); + assert_eq!(registered.len(), 371, "review intentional handler changes"); assert_eq!(allowed, registered, "every registered application command must be granted exactly once while an app ACL manifest exists"); } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e72b69286..1e5e47a4a 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -54,6 +54,11 @@ fn merge_settings_for_save( incoming.current_provider_openclaw = existing.current_provider_openclaw.clone(); incoming.current_provider_hermes = existing.current_provider_hermes.clone(); incoming.appearance_theme = existing.appearance_theme.clone(); + // A stale settings snapshot must not reopen a completed first-use guide. + incoming.first_use_guide_state = existing.first_use_guide_state; + if existing.first_run_notice_confirmed == Some(true) { + incoming.first_run_notice_confirmed = Some(true); + } incoming } @@ -63,6 +68,16 @@ pub async fn get_settings() -> Result { Ok(crate::settings::get_settings_for_frontend()) } +#[tauri::command] +pub async fn get_first_use_guide_state() -> crate::settings::FirstUseGuideState { + crate::settings::get_first_use_guide_state() +} + +#[tauri::command] +pub async fn dismiss_first_use_guide() -> Result { + crate::settings::dismiss_first_use_guide().map_err(|error| error.to_string()) +} + /// Returns the already-frozen host user home used by every default directory. /// On Windows this is Explorer's Shell user rather than the elevated process. #[tauri::command] @@ -644,6 +659,23 @@ mod tests { ); } + #[test] + fn save_settings_should_preserve_existing_first_use_guide_state() { + let existing = crate::settings::AppSettings { + first_use_guide_state: Some(crate::settings::FirstUseGuideState::Dismissed), + first_run_notice_confirmed: Some(true), + ..crate::settings::AppSettings::default() + }; + let incoming = crate::settings::AppSettings { + first_use_guide_state: Some(crate::settings::FirstUseGuideState::Pending), + first_run_notice_confirmed: Some(false), + ..crate::settings::AppSettings::default() + }; + let merged = merge_settings_for_save(incoming, &existing); + assert_eq!(merged.first_use_guide_state, existing.first_use_guide_state); + assert_eq!(merged.first_run_notice_confirmed, Some(true)); + } + #[test] fn save_settings_should_preserve_existing_appearance_theme() { let existing = AppSettings { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e3b8b2329..048509582 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1189,6 +1189,14 @@ pub fn run() { } } + // Only absent local data admits the guide. Inaccessible paths and + // legacy JSON migrations are existing/unknown installs, not new ones. + let new_data_dir = matches!(db_path.try_exists(), Ok(false)) + && matches!(json_path.try_exists(), Ok(false)); + if let Err(error) = crate::settings::initialize_first_use_guide(new_data_dir) { + log::warn!("Unable to initialize first-use guide: {error}"); + } + let db = loop { match crate::database::Database::init() { Ok(db) => break Arc::new(db), @@ -1346,14 +1354,6 @@ pub fn run() { // 落成 "default" provider 设为 current,再追加官方预设(is_current=false)。 // 这样用户切到官方预设时,回填机制会保护原 live 配置不丢失。 // - // 捕获首次运行快照:所有全新装用户都会看到欢迎弹窗介绍 FyAgent 的工作方式。 - // 读失败时默认不弹,宁可漏弹也不要因为故障打扰用户。 - let first_run_already_confirmed = crate::settings::get_settings() - .first_run_notice_confirmed - .unwrap_or(false); - let fresh_install_at_startup = - app_state.db.is_providers_empty().unwrap_or(false); - for app_type in crate::app_config::AppType::all().filter(|t| !t.is_additive_mode()) { @@ -1459,12 +1459,6 @@ pub fn run() { }); } - // 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。 - // 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。 - if !first_run_already_confirmed && fresh_install_at_startup { - log::info!("✓ First-run welcome notice pending"); - } - // 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库 // // additive 模式(OpenCode / OpenClaw)的 import 函数按 id 幂等—— @@ -2081,6 +2075,8 @@ pub fn run() { commands::extract_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, + commands::get_first_use_guide_state, + commands::dismiss_first_use_guide, commands::get_user_home_dir, commands::save_settings, commands::has_codex_unify_history_backup, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 709f67ac5..0be3286a2 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,6 +7,12 @@ use crate::app_config::AppType; use crate::error::AppError; use crate::services::skill::{SkillStorageLocation, SyncMethod}; +mod first_use_guide; +pub use first_use_guide::FirstUseGuideState; +pub(crate) use first_use_guide::{ + dismiss_first_use_guide, get_first_use_guide_state, initialize_first_use_guide, +}; + /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -399,6 +405,9 @@ pub struct AppSettings { /// User has confirmed the first-run welcome notice #[serde(default, skip_serializing_if = "Option::is_none")] pub first_run_notice_confirmed: Option, + /// Device-local first-use eligibility, maintained only by the native guide owner. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_use_guide_state: Option, /// User has confirmed the common config first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub common_config_confirmed: Option, @@ -532,6 +541,7 @@ impl Default for AppSettings { unify_codex_migrate_existing: None, failover_confirmed: None, first_run_notice_confirmed: None, + first_use_guide_state: None, common_config_confirmed: None, language: None, appearance_theme: None, diff --git a/src-tauri/src/settings/first_use_guide.rs b/src-tauri/src/settings/first_use_guide.rs new file mode 100644 index 000000000..ebfd7cb16 --- /dev/null +++ b/src-tauri/src/settings/first_use_guide.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +use super::{get_settings, mutate_settings, AppSettings}; +use crate::error::AppError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FirstUseGuideState { + Pending, + Dismissed, +} + +fn initial_state(settings: &AppSettings, fresh_install: bool) -> FirstUseGuideState { + if settings.first_run_notice_confirmed == Some(true) { + return FirstUseGuideState::Dismissed; + } + settings.first_use_guide_state.unwrap_or(if fresh_install { + FirstUseGuideState::Pending + } else { + FirstUseGuideState::Dismissed + }) +} + +/// Persist eligibility before database creation/seeding so an unfinished guide +/// survives a restart. Missing markers on existing or unreadable data are not +/// evidence of a new install; those users keep the ordinary directory. +pub(crate) fn initialize_first_use_guide(new_data_dir: bool) -> Result<(), AppError> { + let settings = get_settings(); + let no_settings_file = + AppSettings::settings_path().is_some_and(|path| matches!(path.try_exists(), Ok(false))); + if settings.first_use_guide_state.is_none() + && initial_state(&settings, new_data_dir && no_settings_file) == FirstUseGuideState::Pending + { + mutate_settings(|current| { + current.first_use_guide_state = Some(FirstUseGuideState::Pending); + })?; + } + Ok(()) +} + +pub(crate) fn get_first_use_guide_state() -> FirstUseGuideState { + initial_state(&get_settings(), false) +} + +pub(crate) fn dismiss_first_use_guide() -> Result { + mutate_settings(|settings| { + settings.first_use_guide_state = Some(FirstUseGuideState::Dismissed); + settings.first_run_notice_confirmed = Some(true); + })?; + Ok(FirstUseGuideState::Dismissed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_confirmed_fresh_install_enters_the_guide() { + let settings = AppSettings::default(); + assert_eq!(initial_state(&settings, true), FirstUseGuideState::Pending); + assert_eq!( + initial_state(&settings, false), + FirstUseGuideState::Dismissed + ); + } + + #[test] + fn pending_survives_database_creation_and_settings_roundtrip() { + let settings = AppSettings { + first_use_guide_state: Some(FirstUseGuideState::Pending), + ..AppSettings::default() + }; + let json = serde_json::to_string(&settings).expect("serialize settings"); + let restored: AppSettings = serde_json::from_str(&json).expect("restore settings"); + assert_eq!(initial_state(&restored, false), FirstUseGuideState::Pending); + assert!(json.contains("\"firstUseGuideState\":\"pending\"")); + } + + #[test] + fn completed_and_legacy_confirmed_users_never_reenter() { + for fresh_install in [false, true] { + let dismissed = AppSettings { + first_use_guide_state: Some(FirstUseGuideState::Dismissed), + ..AppSettings::default() + }; + assert_eq!( + initial_state(&dismissed, fresh_install), + FirstUseGuideState::Dismissed + ); + for state in [None, Some(FirstUseGuideState::Pending)] { + let legacy = AppSettings { + first_run_notice_confirmed: Some(true), + first_use_guide_state: state, + ..AppSettings::default() + }; + assert_eq!( + initial_state(&legacy, fresh_install), + FirstUseGuideState::Dismissed + ); + } + } + } + + #[test] + fn guide_state_is_closed_and_old_settings_remain_compatible() { + let json = serde_json::to_value(AppSettings::default()).expect("settings"); + assert!(json.get("firstUseGuideState").is_none()); + assert!(serde_json::from_str::("\"unknown\"").is_err()); + assert!(serde_json::from_str::("true").is_err()); + assert_eq!( + serde_json::to_string(&FirstUseGuideState::Dismissed).expect("state"), + "\"dismissed\"" + ); + } +} diff --git a/src/pages/agents/AgentDirectory.tsx b/src/pages/agents/AgentDirectory.tsx index ca7f78dec..4c8fbac39 100644 --- a/src/pages/agents/AgentDirectory.tsx +++ b/src/pages/agents/AgentDirectory.tsx @@ -1,4 +1,10 @@ -import { useCallback, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; @@ -537,10 +543,12 @@ function CodexLifecycleSlot({ } export function AgentDirectory({ + headingRef, entries, scanController, onConfigure, }: { + headingRef?: RefObject; entries: readonly AgentCatalogEntry[]; scanController: AgentDirectoryScanController; onConfigure: (agentId: AgentCatalogId) => void; @@ -579,7 +587,9 @@ export function AgentDirectory({
-

我的 AI 软件

+

+ 我的 AI 软件 +

+ +
+ ) : null} + + + + ); +} diff --git a/src/pages/agents/Page.tsx b/src/pages/agents/Page.tsx index 6e551d22c..77cec26b4 100644 --- a/src/pages/agents/Page.tsx +++ b/src/pages/agents/Page.tsx @@ -1,7 +1,10 @@ -import { useEffect } from "react"; +import { lazy, Suspense, useEffect, useRef } from "react"; import { PRODUCT_DIRECTORY } from "../../shared/features/directory"; -import { useAgentCatalog } from "../../shared/features/queries"; +import { + useAgentCatalog, + useFirstUseGuideState, +} from "../../shared/features/queries"; import { useFrontendReady } from "../../shared/platform/useFrontendReady"; import { usePersistentSearchParams } from "../../shared/ui/usePersistentSearchParams"; import { Button } from "../../shared/ui/Button"; @@ -13,6 +16,12 @@ import { AGENT_SECTION_IDS, type AgentSection } from "./agentSections"; import { useAgentDirectoryScan } from "./useAgentDirectoryScan"; import "./Page.css"; +const FirstUseGuide = lazy(() => + import("./FirstUseGuide").then((module) => ({ + default: module.FirstUseGuide, + })), +); + function agentSection(value: string | null): AgentSection | null { return AGENT_SECTION_IDS.find((section) => section === value) ?? null; } @@ -21,11 +30,6 @@ export function AgentsPage() { const { visible, searchParams, setSearchParams } = usePersistentSearchParams(); const catalogQuery = useAgentCatalog(); - useFrontendReady(!catalogQuery.isPending); - const scanController = useAgentDirectoryScan({ - autoStart: true, - active: visible, - }); const entries = catalogQuery.data?.agents ?? []; const rawTarget = searchParams.get("target"); const rawSection = searchParams.get("section"); @@ -37,6 +41,35 @@ export function AgentsPage() { const catalogEntry = directoryEntry ? entries.find((entry) => entry.id === directoryEntry.agentId) : undefined; + const showDirectory = !directoryEntry; + const guideQuery = useFirstUseGuideState(showDirectory); + const guideLoading = showDirectory && guideQuery.isPending; + const showGuide = showDirectory && guideQuery.data === "pending"; + const headingRef = useRef(null); + const guideWasShown = useRef(false); + useFrontendReady( + !catalogQuery.isPending && + !guideLoading && + (!showGuide || entries.length === 0), + ); + const scanController = useAgentDirectoryScan({ + autoStart: true, + active: visible && !guideLoading && !showGuide, + }); + + useEffect(() => { + if (!visible || !showDirectory) return; + if (showGuide) { + guideWasShown.current = true; + } else if ( + !guideLoading && + !catalogQuery.isPending && + guideWasShown.current + ) { + guideWasShown.current = false; + headingRef.current?.focus({ preventScroll: true }); + } + }, [catalogQuery.isPending, guideLoading, showDirectory, showGuide, visible]); useEffect(() => { if (!visible) return; @@ -63,7 +96,6 @@ export function AgentsPage() { visible, ]); - const showDirectory = !directoryEntry; const returnToDirectory = () => { setSearchParams({}); }; @@ -72,7 +104,9 @@ export function AgentsPage() {
{catalogQuery.error && catalogQuery.data !== undefined ? ( @@ -81,7 +115,7 @@ export function AgentsPage() { ) : null} - {catalogQuery.isPending ? ( + {catalogQuery.isPending || guideLoading ? ( @@ -107,8 +141,19 @@ export function AgentsPage() { description="当前目录没有这个 Agent,请返回软件目录后重试。" actions={} /> + ) : showGuide ? ( + + + + } + > + + ) : showDirectory ? ( diff --git a/src/pages/agents/firstUseRecommendations.ts b/src/pages/agents/firstUseRecommendations.ts new file mode 100644 index 000000000..7b1153dc3 --- /dev/null +++ b/src/pages/agents/firstUseRecommendations.ts @@ -0,0 +1,43 @@ +import type { + AgentCatalogEntry, + AgentCatalogId, +} from "../../shared/features/types"; + +export type GuidePurpose = "office" | "coding" | "both"; + +export const GUIDE_PURPOSES: readonly { + id: GuidePurpose; + label: string; + description: string; +}[] = [ + { id: "office", label: "日常办公", description: "文档、表格与资料整理" }, + { id: "coding", label: "编程开发", description: "写代码、修问题与测试" }, + { id: "both", label: "两者都用", description: "办公与开发都会用" }, +]; + +const recommendedIds: Record = { + office: ["qoderwork", "trae-work", "workbuddy"], + coding: ["codex", "claude-code", "opencode"], + both: ["workbuddy", "codex"], +}; + +const reasons: Partial> = { + qoderwork: "整理文件、处理数据与生成文档", + "trae-work": "文档、演示稿与资料调研", + workbuddy: "处理日常办公任务", + codex: "开发功能、修复问题与检查代码", + "claude-code": "在终端中理解、修改和测试代码", + opencode: "在桌面或终端中编写代码", +}; + +export function firstUseRecommendations( + entries: readonly AgentCatalogEntry[], + purpose: GuidePurpose, +) { + return entries + .filter((entry) => recommendedIds[purpose].includes(entry.id)) + .map((entry) => ({ + entry, + reason: reasons[entry.id] ?? entry.description, + })); +} diff --git a/src/shared/features/first-use-guide.ts b/src/shared/features/first-use-guide.ts new file mode 100644 index 000000000..5cc5c7377 --- /dev/null +++ b/src/shared/features/first-use-guide.ts @@ -0,0 +1,6 @@ +export type FirstUseGuideState = "pending" | "dismissed"; + +export function parseFirstUseGuideState(value: unknown): FirstUseGuideState { + if (value === "pending" || value === "dismissed") return value; + throw new Error("Invalid first-use guide state"); +} diff --git a/src/shared/features/ports.ts b/src/shared/features/ports.ts index 4658ba531..3dfe9d7c0 100644 --- a/src/shared/features/ports.ts +++ b/src/shared/features/ports.ts @@ -1,3 +1,4 @@ +import type { FirstUseGuideState } from "./first-use-guide"; import type { JobSnapshot, LocalInstallStatus, @@ -220,6 +221,8 @@ export interface McpPort { export interface SettingsPort { get(): Promise; save(settings: FeatureSettings): Promise; + getFirstUseGuideState(): Promise; + dismissFirstUseGuide(): Promise<"dismissed">; openExternal(url: string): Promise; } diff --git a/src/shared/features/queries.ts b/src/shared/features/queries.ts index 68454fbd6..1bfcd5245 100644 --- a/src/shared/features/queries.ts +++ b/src/shared/features/queries.ts @@ -68,8 +68,22 @@ export const featureKeys = { [...dailyMemorySearchKey, query] as const, dailyMemorySearches: dailyMemorySearchKey, settings: [scope, "settings"] as const, + firstUseGuide: [scope, "first-use-guide"] as const, }; +export function useFirstUseGuideState(enabled = true) { + const { ports } = useFeatures(); + return useQuery({ + queryKey: featureKeys.firstUseGuide, + queryFn: ports.settings.getFirstUseGuideState, + enabled: useVisibleEnabled(enabled), + staleTime: Infinity, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + export function agentHealthQueryOptions( port: HealthPort, agentId: AgentCatalogId, diff --git a/src/shared/platform/browser/features.ts b/src/shared/platform/browser/features.ts index c3ff091a5..a73d77e28 100644 --- a/src/shared/platform/browser/features.ts +++ b/src/shared/platform/browser/features.ts @@ -183,6 +183,8 @@ export function createBrowserFeaturePorts(): FeaturePorts { settings: { get: async () => ({}), save: rejectNativeOnly, + getFirstUseGuideState: async () => "dismissed", + dismissFirstUseGuide: rejectNativeOnly, openExternal: rejectNativeOnly, }, tooling: { diff --git a/src/shared/platform/tauri/feature-ports/simple.ts b/src/shared/platform/tauri/feature-ports/simple.ts index 74f14abc3..d49ec144b 100644 --- a/src/shared/platform/tauri/feature-ports/simple.ts +++ b/src/shared/platform/tauri/feature-ports/simple.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { FeaturePorts } from "../../../features/ports"; +import { parseFirstUseGuideState } from "../../../features/first-use-guide"; function validateExternalUrl(url: string): void { let parsed: URL; @@ -66,6 +67,18 @@ export function createSimpleFeaturePorts(): Pick< settings: { get: () => invoke("get_settings"), save: (settings) => invoke("save_settings", { settings }), + getFirstUseGuideState: async () => + parseFirstUseGuideState( + await invoke("get_first_use_guide_state"), + ), + dismissFirstUseGuide: async () => { + const state = parseFirstUseGuideState( + await invoke("dismiss_first_use_guide"), + ); + if (state !== "dismissed") + throw new Error("First-use guide was not dismissed"); + return state; + }, openExternal: async (url) => { validateExternalUrl(url); await invoke("open_external", { url }); diff --git a/tests/browser/first-use-guide.spec.ts b/tests/browser/first-use-guide.spec.ts new file mode 100644 index 000000000..1c2e7e715 --- /dev/null +++ b/tests/browser/first-use-guide.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from "@playwright/test"; + +import { + expectHealthyPage, + expectNoHorizontalOverflow, + monitorPageHealth, + openRendererPage, +} from "./support"; +import { + featureFixtureCalls, + installRichTauriFeatureFixture, +} from "./support/features"; + +for (const choice of [ + { label: "日常办公", names: ["QoderWork CN", "TRAE Work CN", "WorkBuddy"] }, + { label: "编程开发", names: ["Codex", "Claude Code", "OpenCode"] }, + { label: "两者都用", names: ["WorkBuddy", "Codex"] }, +]) { + test(`first use recommends ${choice.label} and persists completion`, async ({ + page, + }) => { + await installRichTauriFeatureFixture(page, { + firstUseGuideState: "pending", + }); + const health = monitorPageHealth(page); + await openRendererPage(page, "/agents"); + const guide = page.getByRole("region", { name: "首次使用引导" }); + await expect( + guide.getByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toBeFocused(); + await guide + .getByRole("button", { name: choice.label, exact: true }) + .click(); + await expect(guide.getByRole("heading", { level: 2 })).toHaveText( + choice.names, + ); + const calls = await featureFixtureCalls(page); + expect(calls.map((call) => call.command)).not.toContain( + "get_agent_install_readiness", + ); + expect(calls.map((call) => call.command)).not.toContain( + "start_agent_action", + ); + expect(calls.map((call) => call.command)).not.toContain("save_settings"); + await guide.getByRole("button", { name: "重新选择" }).click(); + await expect( + guide.getByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toBeFocused(); + await guide + .getByRole("button", { name: choice.label, exact: true }) + .click(); + await guide.getByRole("button", { name: "查看全部软件" }).click(); + await expect( + page.getByRole("heading", { name: "我的 AI 软件" }), + ).toBeFocused(); + await expect(page.locator(".fy-agent-directory-card")).toHaveCount(7); + const descriptions = await page + .locator(".fy-agent-directory-description") + .allTextContents(); + expect( + descriptions.every((text) => !/不支持|不安装|暂无法确认/u.test(text)), + ).toBe(true); + await page.reload(); + await expect( + page.getByRole("heading", { name: "我的 AI 软件" }), + ).toBeVisible(); + await expect(guide).toHaveCount(0); + await expectHealthyPage(page, health); + }); +} + +test("skip works before and after a recommendation, and unfinished onboarding resumes", async ({ + page, +}) => { + await installRichTauriFeatureFixture(page, { firstUseGuideState: "pending" }); + const health = monitorPageHealth(page); + await openRendererPage(page, "/agents"); + await page.getByRole("button", { name: "日常办公", exact: true }).click(); + await page.reload(); + await expect( + page.getByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toBeVisible(); + await page.getByRole("button", { name: "跳过引导" }).click(); + await page.reload(); + await expect( + page.getByRole("heading", { name: "我的 AI 软件" }), + ).toBeVisible(); + await expectHealthyPage(page, health); +}); + +for (const theme of ["light", "dark"] as const) { + test(`first-use guide stays reachable with keyboard and viewport changes in ${theme}`, async ({ + page, + browserName, + }, testInfo) => { + // WebKit's all-controls navigation respects the host keyboard-access policy. + // Option-Tab reaches buttons even when ordinary Tab skips them on macOS. + const tabKey = browserName === "webkit" ? "Alt+Tab" : "Tab"; + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await installRichTauriFeatureFixture(page, { + firstUseGuideState: "pending", + }); + const health = monitorPageHealth(page); + await openRendererPage(page, "/agents"); + const guide = page.getByRole("region", { name: "首次使用引导" }); + await expect(guide).toBeVisible(); + for (const viewport of [ + { width: 1232, height: 700 }, + { width: 900, height: 600 }, + { width: 1232, height: 700 }, + ]) { + await page.setViewportSize(viewport); + await expectNoHorizontalOverflow(page); + for (const name of ["日常办公", "编程开发", "两者都用", "跳过引导"]) { + await guide + .getByRole("button", { name, exact: true }) + .click({ trial: true }); + } + } + await page.screenshot({ + path: testInfo.outputPath(`guide-question-${theme}.png`), + }); + await guide.getByRole("heading", { level: 1 }).focus(); + await page.keyboard.press(tabKey); + await expect( + guide.getByRole("button", { name: "日常办公", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Enter"); + await expect( + guide.getByRole("heading", { name: "推荐你从这些软件开始" }), + ).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath(`guide-recommendations-${theme}.png`), + }); + await page.keyboard.press(tabKey); + await expect(guide.getByRole("button", { name: "重新选择" })).toBeFocused(); + await page.keyboard.press(tabKey); + await page.keyboard.press(tabKey); + await expect(guide.getByRole("button", { name: "跳过引导" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect( + page.getByRole("heading", { name: "我的 AI 软件" }), + ).toBeFocused(); + await page.reload(); + await expect(guide).toHaveCount(0); + await expectHealthyPage(page, health); + }); +} diff --git a/tests/browser/navigation-performance.spec.ts b/tests/browser/navigation-performance.spec.ts index a6d21e719..010da858e 100644 --- a/tests/browser/navigation-performance.spec.ts +++ b/tests/browser/navigation-performance.spec.ts @@ -28,6 +28,38 @@ test("production boots all eight primary routes without initialization errors", expect(errors).toEqual([]); }); +test("production boots the first-use guide on demand and persists skipping", async ({ + page, +}) => { + const errors: string[] = []; + const scripts: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (request.resourceType() === "script") scripts.push(request.url()); + }); + await installRichTauriFeatureFixture(page, { firstUseGuideState: "pending" }); + await page.goto("/#/agents"); + await expect( + page.getByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toBeFocused(); + const guideScripts = scripts.filter((url) => + /\/FirstUseGuide-[^/]+\.js$/u.test(url), + ); + expect(guideScripts).toHaveLength(1); + await page.getByRole("button", { name: "两者都用", exact: true }).click(); + await expect(page.getByRole("heading", { level: 2 })).toHaveText([ + "WorkBuddy", + "Codex", + ]); + await page.getByRole("button", { name: "跳过引导" }).click(); + await expect(page.locator(".fy-agent-directory-card")).toHaveCount(7); + scripts.length = 0; + await page.reload(); + await expect(page.locator(".fy-agent-directory-card")).toHaveCount(7); + expect(scripts.some((url) => guideScripts.includes(url))).toBe(false); + expect(errors).toEqual([]); +}); + for (const cpuRate of [1, 4]) { test(`production navigation at ${cpuRate}x CPU cost`, async ({ page, diff --git a/tests/browser/support/features.ts b/tests/browser/support/features.ts index 9aaed148e..d0f88acfa 100644 --- a/tests/browser/support/features.ts +++ b/tests/browser/support/features.ts @@ -12,6 +12,7 @@ export interface FeatureFixtureCall { } export interface RichFeatureFixtureOptions { + firstUseGuideState?: "pending" | "dismissed"; healthFailure?: AgentCatalogId; healthStale?: boolean; catalogFailure?: boolean; @@ -86,6 +87,12 @@ export async function installRichTauriFeatureFixture( healthSnapshots, }; await page.addInitScript((fixtureOptions: PreparedFixtureOptions) => { + // Browser-only persistence models the native device setting across reloads. + const guideStorageKey = "fyagent-test-first-use-guide"; + let firstUseGuideState = + localStorage.getItem(guideStorageKey) ?? + fixtureOptions.firstUseGuideState ?? + "dismissed"; let healthFailure = fixtureOptions.healthFailure; let healthGate: Promise | null = null; let releaseHealth = () => {}; @@ -205,7 +212,7 @@ export async function installRichTauriFeatureFixture( id: "qoderwork", variantId: "qoderwork-cn", displayName: "QoderWork CN", - description: "Qoder 家族的桌面工作助手;当前仅提供官方入口。", + description: "支持 Skills 同步与 MCP 直接分配。", officialLinks: [ { id: "product", @@ -220,7 +227,7 @@ export async function installRichTauriFeatureFixture( variantId: "trae-work-cn", displayName: "TRAE Work CN", description: - "支持 Skills 同步、模型配置与 MCP 直接分配;不支持 Hooks。", + "支持 Skills 同步与 MCP 直接分配;自定义模型可在 TRAE Work CN 中添加。", officialLinks: [ { id: "product", @@ -234,8 +241,7 @@ export async function installRichTauriFeatureFixture( id: "workbuddy", variantId: "workbuddy", displayName: "WorkBuddy", - description: - "支持 Skills 同步、模型配置与 MCP 直接分配;不支持 Hooks。", + description: "支持 Skills 同步、模型配置与 MCP 直接分配。", officialLinks: [ { id: "product", @@ -249,8 +255,7 @@ export async function installRichTauriFeatureFixture( id: "grokbuild", variantId: "grokbuild", displayName: "Grok Build", - description: - "支持 Skills 同步、模型配置与 MCP 直接分配。本机识别和启动暂无法确认。", + description: "支持 Skills 同步、模型配置与 MCP 直接分配。", officialLinks: [ { id: "product", @@ -264,7 +269,7 @@ export async function installRichTauriFeatureFixture( id: "codex", variantId: "codex", displayName: "Codex", - description: "支持桌面安装、Skills、模型配置与 MCP;不支持 Hooks。", + description: "支持桌面安装、Skills、模型配置与 MCP。", officialLinks: [], capabilities: catalogCapabilities("codex"), }, @@ -272,7 +277,8 @@ export async function installRichTauriFeatureFixture( id: "claude-code", variantId: "claude-code", displayName: "Claude Code", - description: "支持 Skills、模型配置与 MCP;不支持 Hooks。", + description: + "支持 Claude Code CLI 安装、官方登录、Skills、模型配置与 MCP。", officialLinks: [ { id: "product", @@ -286,7 +292,7 @@ export async function installRichTauriFeatureFixture( id: "opencode", variantId: "opencode", displayName: "OpenCode", - description: "支持 Skills、模型配置与 MCP;不支持 Hooks。", + description: "支持 Skills、模型配置与 MCP。", officialLinks: [ { id: "product", @@ -1751,6 +1757,12 @@ export async function installRichTauriFeatureFixture( }; return structuredClone(record.snapshot); } + case "get_first_use_guide_state": + return firstUseGuideState; + case "dismiss_first_use_guide": + firstUseGuideState = "dismissed"; + localStorage.setItem(guideStorageKey, firstUseGuideState); + return firstUseGuideState; case "get_settings": return { skillSyncMethod: "auto", diff --git a/tests/renderer/pages/agents/Page.test.tsx b/tests/renderer/pages/agents/Page.test.tsx index f07fb9e38..fcbc473f9 100644 --- a/tests/renderer/pages/agents/Page.test.tsx +++ b/tests/renderer/pages/agents/Page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, useLocation } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; @@ -31,6 +31,9 @@ import { type PromptAppId, } from "@/shared/features/types"; import { createBrowserFeaturePorts } from "@/shared/platform/browser/features"; +import type { FirstUseGuideState } from "@/shared/features/first-use-guide"; +import { firstUseRecommendations } from "@/pages/agents/firstUseRecommendations"; +import { PersistentSurface } from "@/shared/ui/PersistentSurface"; const capabilityIds: readonly AgentCapabilityId[] = [ "product.open", @@ -431,6 +434,238 @@ function deferred() { return { promise, resolve, reject }; } +describe("first-use software guide", () => { + function firstUsePorts() { + const ports = configuredPorts(); + let state: FirstUseGuideState = "pending"; + ports.settings.getFirstUseGuideState = vi.fn(async () => state); + ports.settings.dismissFirstUseGuide = vi.fn(async () => { + state = "dismissed"; + return "dismissed" as const; + }); + ports.settings.save = vi.fn(); + return ports; + } + + it.each([ + ["日常办公", ["QoderWork CN", "TRAE Work CN", "WorkBuddy"]], + ["编程开发", ["Codex", "Claude Code", "OpenCode"]], + ["两者都用", ["WorkBuddy", "Codex"]], + ])( + "recommends catalog entries for %s without side effects", + async (choice, names) => { + const user = userEvent.setup(); + const ports = firstUsePorts(); + renderPage(ports); + expect( + await screen.findByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toHaveFocus(); + await user.click(screen.getByRole("button", { name: choice })); + expect( + screen.getByRole("heading", { name: "推荐你从这些软件开始" }), + ).toHaveFocus(); + expect( + screen + .getAllByRole("heading", { level: 2 }) + .map((node) => node.textContent), + ).toEqual(names); + expect(screen.getByRole("button", { name: "跳过引导" })).toBeEnabled(); + expect(ports.settings.save).not.toHaveBeenCalled(); + expect(ports.settings.dismissFirstUseGuide).not.toHaveBeenCalled(); + expect(ports.agentInstallReadiness.get).not.toHaveBeenCalled(); + expect(ports.agentInstallReadiness.startAction).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "重新选择" })); + expect( + screen.getByRole("heading", { name: "你主要想用 AI 做什么?" }), + ).toHaveFocus(); + }, + ); + + it.each(["skip", "complete", "skip-recommendations"])( + "persists %s and does not reopen with a new query client", + async (action) => { + const user = userEvent.setup(); + const ports = firstUsePorts(); + const view = renderPage(ports); + await screen.findByRole("heading", { name: "你主要想用 AI 做什么?" }); + if (action !== "skip") { + await user.click(screen.getByRole("button", { name: "两者都用" })); + } + await user.click( + screen.getByRole("button", { + name: action === "complete" ? "查看全部软件" : "跳过引导", + }), + ); + expect( + await screen.findByRole("heading", { name: "我的 AI 软件" }), + ).toHaveFocus(); + expect(ports.settings.dismissFirstUseGuide).toHaveBeenCalledTimes(1); + expect(ports.settings.save).not.toHaveBeenCalled(); + expect(ports.agentInstallReadiness.startAction).not.toHaveBeenCalled(); + view.unmount(); + renderPage(ports); + await screen.findByRole("heading", { name: "我的 AI 软件" }); + expect( + screen.queryByRole("region", { name: "首次使用引导" }), + ).not.toBeInTheDocument(); + }, + ); + + it("keeps the chosen step after a save failure and allows a safe retry", async () => { + const user = userEvent.setup(); + const ports = firstUsePorts(); + vi.mocked(ports.settings.dismissFirstUseGuide).mockRejectedValueOnce( + new Error("private native path"), + ); + renderPage(ports); + await user.click(await screen.findByRole("button", { name: "编程开发" })); + await user.click(screen.getByRole("button", { name: "查看全部软件" })); + expect( + await screen.findByText("暂时无法保存引导状态,请重试。"), + ).toBeVisible(); + expect(screen.queryByText("private native path")).not.toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(3); + expect(ports.agentInstallReadiness.get).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "查看全部软件" })); + await screen.findByRole("heading", { name: "我的 AI 软件" }); + }); + + it("waits for native persistence and admits only one in-flight dismissal", async () => { + const user = userEvent.setup(); + const ports = firstUsePorts(); + const write = deferred<"dismissed">(); + ports.settings.dismissFirstUseGuide = vi.fn(() => write.promise); + renderPage(ports); + await user.dblClick( + await screen.findByRole("button", { name: "跳过引导" }), + ); + expect(ports.settings.dismissFirstUseGuide).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "日常办公" })).toBeDisabled(); + expect( + screen.queryByRole("heading", { name: "我的 AI 软件" }), + ).not.toBeInTheDocument(); + await act(async () => write.resolve("dismissed")); + await screen.findByRole("heading", { name: "我的 AI 软件" }); + }); + + it("does not flash a directory or signal ready before the first-use read settles", async () => { + const ports = firstUsePorts(); + const read = deferred(); + ports.settings.getFirstUseGuideState = vi.fn(() => read.promise); + const signal = vi + .spyOn(frontendLifecycle, "signalFrontendReady") + .mockResolvedValue(undefined); + renderPage(ports); + await waitFor(() => expect(ports.catalog.get).toHaveBeenCalled()); + expect( + screen.queryByRole("region", { name: "AI 软件目录" }), + ).not.toBeInTheDocument(); + expect(signal).not.toHaveBeenCalled(); + await act(async () => read.resolve("pending")); + await screen.findByRole("heading", { name: "你主要想用 AI 做什么?" }); + await waitFor(() => expect(signal).toHaveBeenCalledTimes(1)); + }); + + it("does not treat a failed first-use read as a fresh installation", async () => { + const ports = firstUsePorts(); + ports.settings.getFirstUseGuideState = vi + .fn() + .mockRejectedValue(new Error("unavailable")); + renderPage(ports); + await screen.findByRole("heading", { name: "我的 AI 软件" }); + expect( + screen.queryByRole("region", { name: "首次使用引导" }), + ).not.toBeInTheDocument(); + expect(ports.settings.dismissFirstUseGuide).not.toHaveBeenCalled(); + }); + + it("keeps a fresh-user catalog error ready without waiting for guide content", async () => { + const ports = firstUsePorts(); + ports.catalog.get = vi.fn().mockRejectedValue(new Error("unavailable")); + const signal = vi + .spyOn(frontendLifecycle, "signalFrontendReady") + .mockResolvedValue(undefined); + renderPage(ports); + await screen.findByText("无法加载 Agent 目录", {}, { timeout: 3000 }); + await waitFor(() => expect(signal).toHaveBeenCalledTimes(1)); + expect( + screen.queryByRole("region", { name: "首次使用引导" }), + ).not.toBeInTheDocument(); + expect(ports.settings.dismissFirstUseGuide).not.toHaveBeenCalled(); + }); + + it("leaves explicit configuration links in control", async () => { + const ports = firstUsePorts(); + renderPage(ports, "/agents?target=codex§ion=models"); + await waitFor(() => + expect(screen.getByTestId("agents-page")).toHaveAttribute( + "data-view", + "configuration", + ), + ); + expect(ports.settings.getFirstUseGuideState).not.toHaveBeenCalled(); + expect( + screen.queryByRole("region", { name: "首次使用引导" }), + ).not.toBeInTheDocument(); + }); + + it.each(["success", "failure"])( + "reconciles hidden dismissal %s without scanning or stealing focus", + async (outcome) => { + const user = userEvent.setup(); + const ports = firstUsePorts(); + const write = deferred<"dismissed">(); + ports.settings.dismissFirstUseGuide = vi + .fn() + .mockImplementationOnce(() => write.promise) + .mockResolvedValue("dismissed"); + const view = (active: boolean) => ( + + + + + + + + + ); + const rendered = render(view(true)); + await user.click(await screen.findByRole("button", { name: "跳过引导" })); + rendered.rerender(view(false)); + await user.click(screen.getByRole("button", { name: "Other page" })); + await act(async () => { + if (outcome === "success") write.resolve("dismissed"); + else write.reject(new Error("unavailable")); + }); + expect(screen.getByRole("button", { name: "Other page" })).toHaveFocus(); + expect(ports.agentInstallReadiness.get).not.toHaveBeenCalled(); + expect( + screen.queryByText("暂时无法保存引导状态,请重试。"), + ).not.toBeInTheDocument(); + rendered.rerender(view(true)); + if (outcome === "failure") { + expect( + await screen.findByText("暂时无法保存引导状态,请重试。"), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "跳过引导" })); + } + await screen.findByRole("heading", { name: "我的 AI 软件" }); + await waitFor(() => + expect(ports.agentInstallReadiness.get).toHaveBeenCalled(), + ); + }, + ); + + it("only recommends supplied catalog identities and uses their current names", () => { + const renamed = { ...entry("codex", "Current catalog name") }; + expect(firstUseRecommendations([renamed], "coding")).toEqual([ + { entry: renamed, reason: "开发功能、修复问题与检查代码" }, + ]); + expect(firstUseRecommendations([renamed], "office")).toEqual([]); + expect(firstUseRecommendations([], "both")).toEqual([]); + }); +}); + const CATALOG_NAMES = [ "QoderWork CN", "TRAE Work CN", @@ -634,7 +869,7 @@ describe("V3 Agent directory and configuration shell", () => { ).toBeEnabled(); scanComplete = true; expect( - within(directoryArticle("QoderWork CN")).getByRole("button", { + await within(directoryArticle("QoderWork CN")).findByRole("button", { name: "一键安装", }), ).toBeVisible(); @@ -759,7 +994,7 @@ describe("V3 Agent directory and configuration shell", () => { await screen.findByRole("button", { name: "重新扫描" }), ).toBeEnabled(); await user.click( - within(directoryArticle("QoderWork CN")).getByRole("button", { + await within(directoryArticle("QoderWork CN")).findByRole("button", { name: "选择安装目标", }), ); @@ -860,7 +1095,7 @@ describe("V3 Agent directory and configuration shell", () => { scanComplete = true; await user.click( - within(directoryArticle("QoderWork CN")).getByRole("button", { + await within(directoryArticle("QoderWork CN")).findByRole("button", { name: "一键安装", }), ); @@ -929,7 +1164,7 @@ describe("V3 Agent directory and configuration shell", () => { scanComplete = true; await user.click( - within(directoryArticle("QoderWork CN")).getByRole("button", { + await within(directoryArticle("QoderWork CN")).findByRole("button", { name: "一键安装", }), ); @@ -989,7 +1224,7 @@ describe("V3 Agent directory and configuration shell", () => { await screen.findByRole("button", { name: "重新扫描" }), ).toBeEnabled(); expect( - within(directoryArticle("OpenCode")).getByRole("button", { + await within(directoryArticle("OpenCode")).findByRole("button", { name: "一键更新", }), ).toBeVisible(); diff --git a/tests/renderer/platform/firstUseGuidePort.test.ts b/tests/renderer/platform/firstUseGuidePort.test.ts new file mode 100644 index 000000000..5bb745893 --- /dev/null +++ b/tests/renderer/platform/firstUseGuidePort.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; + +import { parseFirstUseGuideState } from "@/shared/features/first-use-guide"; +import { createSimpleFeaturePorts } from "@/shared/platform/tauri/feature-ports/simple"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); + +describe("first-use guide native boundary", () => { + it.each(["pending", "dismissed"])("accepts %s", (state) => { + expect(parseFirstUseGuideState(state)).toBe(state); + }); + + it.each([ + null, + undefined, + true, + false, + 0, + "", + "completed", + {}, + { state: "pending" }, + [], + ])("rejects unknown wire input %j", (value) => + expect(() => parseFirstUseGuideState(value)).toThrow(), + ); + + it("uses parameter-free commands instead of resubmitting settings", async () => { + invoke.mockResolvedValueOnce("pending").mockResolvedValueOnce("dismissed"); + const { settings } = createSimpleFeaturePorts(); + await expect(settings.getFirstUseGuideState()).resolves.toBe("pending"); + await expect(settings.dismissFirstUseGuide()).resolves.toBe("dismissed"); + expect(invoke.mock.calls).toEqual([ + ["get_first_use_guide_state"], + ["dismiss_first_use_guide"], + ]); + }); + + it("does not accept a pending write acknowledgement as completion", async () => { + invoke.mockResolvedValueOnce("pending"); + await expect( + createSimpleFeaturePorts().settings.dismissFirstUseGuide(), + ).rejects.toThrow("not dismissed"); + }); +}); diff --git a/tests/renderer/platform/tauriAclContract.test.ts b/tests/renderer/platform/tauriAclContract.test.ts index b8fa8a976..f7af9221d 100644 --- a/tests/renderer/platform/tauriAclContract.test.ts +++ b/tests/renderer/platform/tauriAclContract.test.ts @@ -124,7 +124,9 @@ describe("Native ACL contract", () => { const allowed = activeAclCommands(); expect(renderer.dynamicInvokes).toEqual([]); - expect(renderer.commands.size).toBe(111); + expect(renderer.commands.size).toBe(113); + expect(renderer.commands.has("get_first_use_guide_state")).toBe(true); + expect(renderer.commands.has("dismiss_first_use_guide")).toBe(true); expect(renderer.commands.has("get_agent_health")).toBe(true); expect(renderer.commands.has("set_window_theme")).toBe(true); expect( From 9c616da971fa2e9120746cfd8a1fb65a894b2040 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:09:25 +0800 Subject: [PATCH 2/9] chore(task): archive 09-15-first-use-software-guide --- .../09-15-first-use-software-guide/task.json | 26 --------------- .../check.jsonl | 0 .../09-15-first-use-software-guide/design.md | 0 .../implement.jsonl | 0 .../implement.md | 0 .../09-15-first-use-software-guide/prd.md | 0 .../09-15-first-use-software-guide/task.json | 33 +++++++++++++++++++ 7 files changed, 33 insertions(+), 26 deletions(-) delete mode 100644 .trellis/tasks/09-15-first-use-software-guide/task.json rename .trellis/tasks/{ => archive/2026-09}/09-15-first-use-software-guide/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-first-use-software-guide/design.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-first-use-software-guide/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-first-use-software-guide/implement.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-first-use-software-guide/prd.md (100%) create mode 100644 .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/task.json diff --git a/.trellis/tasks/09-15-first-use-software-guide/task.json b/.trellis/tasks/09-15-first-use-software-guide/task.json deleted file mode 100644 index a99e95800..000000000 --- a/.trellis/tasks/09-15-first-use-software-guide/task.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "first-use-software-guide", - "name": "first-use-software-guide", - "title": "AI 软件正向文案与首次使用推荐引导", - "description": "调整首页软件能力描述,新增可跳过且仅首次展示的用途推荐引导,验证并更新 SPEC 后归档。", - "status": "in_progress", - "dev_type": null, - "scope": null, - "package": null, - "priority": "P2", - "creator": "pythonrust", - "assignee": "pythonrust", - "createdAt": "2026-09-15", - "completedAt": null, - "branch": "dev/laiyongjie", - "base_branch": "main", - "worktree_path": null, - "commit": null, - "pr_url": null, - "subtasks": [], - "children": [], - "parent": null, - "relatedFiles": [], - "notes": "", - "meta": {} -} \ No newline at end of file diff --git a/.trellis/tasks/09-15-first-use-software-guide/check.jsonl b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/check.jsonl similarity index 100% rename from .trellis/tasks/09-15-first-use-software-guide/check.jsonl rename to .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/check.jsonl diff --git a/.trellis/tasks/09-15-first-use-software-guide/design.md b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/design.md similarity index 100% rename from .trellis/tasks/09-15-first-use-software-guide/design.md rename to .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/design.md diff --git a/.trellis/tasks/09-15-first-use-software-guide/implement.jsonl b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/implement.jsonl similarity index 100% rename from .trellis/tasks/09-15-first-use-software-guide/implement.jsonl rename to .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/implement.jsonl diff --git a/.trellis/tasks/09-15-first-use-software-guide/implement.md b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/implement.md similarity index 100% rename from .trellis/tasks/09-15-first-use-software-guide/implement.md rename to .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/implement.md diff --git a/.trellis/tasks/09-15-first-use-software-guide/prd.md b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/prd.md similarity index 100% rename from .trellis/tasks/09-15-first-use-software-guide/prd.md rename to .trellis/tasks/archive/2026-09/09-15-first-use-software-guide/prd.md diff --git a/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/task.json b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/task.json new file mode 100644 index 000000000..c0f619051 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-15-first-use-software-guide/task.json @@ -0,0 +1,33 @@ +{ + "id": "first-use-software-guide", + "name": "first-use-software-guide", + "title": "AI 软件正向文案与首次使用推荐引导", + "description": "调整首页软件能力描述,新增可跳过且仅首次展示的用途推荐引导,验证并更新 SPEC 后归档。", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pythonrust", + "assignee": "pythonrust", + "createdAt": "2026-09-15", + "completedAt": "2026-09-15", + "branch": "dev/laiyongjie", + "base_branch": "main", + "worktree_path": null, + "commit": "ae1da25a125d23ca7823d3ebc4ac570fd662d020", + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "src/pages/agents/Page.tsx", + "src/pages/agents/FirstUseGuide.tsx", + "src-tauri/src/settings/first_use_guide.rs", + "src-tauri/src/commands/agent_catalog.rs", + ".trellis/spec/frontend/first-use-guide.md", + ".trellis/spec/backend/first-use-guide.md" + ], + "notes": "R1–R7 已完成。SPEC 与验收记录已随工作提交更新;归档前完整门禁、42 项浏览器回归和 3 项生产启动测试通过。自动化证据不等同于真机全新安装验收;未推送远端。", + "meta": {} +} \ No newline at end of file From 5ce6a95da5ac766675442b56160a9ebbb9020931 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:10:04 +0800 Subject: [PATCH 3/9] chore: record journal --- .trellis/workspace/pythonrust/index.md | 7 +++-- .trellis/workspace/pythonrust/journal-2.md | 34 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/pythonrust/index.md b/.trellis/workspace/pythonrust/index.md index cb41c805d..0b0fc1d10 100644 --- a/.trellis/workspace/pythonrust/index.md +++ b/.trellis/workspace/pythonrust/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-2.md` -- **Total Sessions**: 90 -- **Last Active**: 2026-09-14 +- **Total Sessions**: 91 +- **Last Active**: 2026-09-15 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-2.md` | ~849 | Active | +| `journal-2.md` | ~883 | Active | | `journal-1.md` | ~1987 | Archived | @@ -30,6 +30,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 91 | 2026-09-15 | AI 软件正向文案与可跳过的首次推荐引导 | `ae1da25a125d23ca7823d3ebc4ac570fd662d020` | `dev/laiyongjie` | | 90 | 2026-09-14 | Stabilize merge-queue contrast sampling | `5be5540c` | `dev/laiyongjie` | | 89 | 2026-09-14 | Audit ahead-of-main commits and split SPEC owners | `20dd6537` | `dev/laiyongjie` | | 88 | 2026-09-14 | Simplify secondary pages and close validation gaps | `379bb0d113702421779e01eb4f49f4cf6c13c0aa`, `ee475a784f6995ecfd933b3a6b78fe1ae8950193` | `dev/laiyongjie` | diff --git a/.trellis/workspace/pythonrust/journal-2.md b/.trellis/workspace/pythonrust/journal-2.md index f54951da4..c6928035d 100644 --- a/.trellis/workspace/pythonrust/journal-2.md +++ b/.trellis/workspace/pythonrust/journal-2.md @@ -847,3 +847,37 @@ Diagnosed PR #188 merge-group WebKit failure as a two-phase raster sampling race ### Status [OK] **Completed** + + +## Session 91: AI 软件正向文案与可跳过的首次推荐引导 + + +**Date**: 2026-09-15 +**Task**: AI 软件正向文案与可跳过的首次推荐引导 +**Branch**: `dev/laiyongjie` + +### Summary + +完成首页七个软件的正向能力简介与首次用途推荐引导;SPEC 已随工作提交更新,任务已归档。未推送远端、未生成发行安装包,未进行真机全新安装验收。 + +### Main Changes + +- 首次本机状态在数据库初始化前保存;跳过或完成后不再展示,旧设置快照不能覆盖完成状态。 +- 办公、编程、混合用途推荐采用现有目录;引导按需加载,不自动安装、登录或改模型。 +- 新增前后端 first-use-guide SPEC,更新目录与文案约定并完成任务归档。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `ae1da25a125d23ca7823d3ebc4ac570fd662d020` | feat(agents): add skippable first-use software recommendations | + +### Testing + +- [OK] 归档前完整门禁通过:前端 1678 通过、1 项既有跳过;Rust 3570 通过、6 项既有条件忽略;桌面 mock 7 通过。 +- [OK] Chromium/WebKit 目录及引导 42/42,生产启动 3/3;初始 JS 665004 字节,保持 665600 字节预算。 +- [OK] 聚焦页面、端口与权限单测 47/47;类型、lint、格式、Rust check/Clippy 与契约检查通过。 + +### Status + +[OK] **Completed** From 4eb7e346b88b9386710d2b21e5769d8b4d2d3ff7 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:44:05 +0800 Subject: [PATCH 4/9] fix(agents): include Grok Build in first-use recommendations --- .trellis/spec/frontend/first-use-guide.md | 13 ++++- .../check.jsonl | 2 + .../implement.jsonl | 2 + .../implement.md | 52 +++++++++++++++++++ .../09-15-grok-guide-recommendation/prd.md | 30 +++++++++++ .../09-15-grok-guide-recommendation/task.json | 31 +++++++++++ src/pages/agents/firstUseRecommendations.ts | 3 +- tests/browser/first-use-guide.spec.ts | 27 +++++++++- tests/renderer/pages/agents/Page.test.tsx | 30 ++++++++++- 9 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 .trellis/tasks/09-15-grok-guide-recommendation/check.jsonl create mode 100644 .trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl create mode 100644 .trellis/tasks/09-15-grok-guide-recommendation/implement.md create mode 100644 .trellis/tasks/09-15-grok-guide-recommendation/prd.md create mode 100644 .trellis/tasks/09-15-grok-guide-recommendation/task.json diff --git a/.trellis/spec/frontend/first-use-guide.md b/.trellis/spec/frontend/first-use-guide.md index c7a8ab5f3..9245be542 100644 --- a/.trellis/spec/frontend/first-use-guide.md +++ b/.trellis/spec/frontend/first-use-guide.md @@ -41,10 +41,16 @@ rejects native-only dismissal instead of pretending to save. Both steps expose skip. Selection shows a small set of recommendations, with back/reselect and a full-directory action; it is not an installation wizard. - Office recommends QoderWork CN, TRAE Work CN and WorkBuddy; coding recommends - Codex, Claude Code and OpenCode; combined use recommends WorkBuddy and Codex. + Grok Build, Codex, Claude Code and OpenCode; combined use recommends WorkBuddy + and Codex. These are purpose associations, not capability, platform or quality rankings. Intersect IDs with the parsed catalog and preserve its names/order. Do not create a fallback catalog or infer installation/action permissions. + For the current seven products, the office/coding union covers the complete + catalog, with a concise purpose description for each. Cross-check against + `AGENT_CATALOG_IDS`; do not silently omit a product by testing only a copied + shortlist. Future deliberate exclusions require a recorded product rationale. + Combined use remains a curated entry point, not the full catalog union. - Purpose is component-local. Choosing it performs no native write, auth, install, model change or telemetry. Directory scanning starts only after the first-use check settles with no guide or dismissal succeeds. @@ -82,11 +88,16 @@ existing installations through onboarding after an upgrade. completion, new query-client restart, delayed/failed persistence, duplicate clicks, unknown startup reads, target links and hidden completion. Native-port tests reject unknown states and pending write acknowledgements. +The office/coding coverage assertion compares recommendation identities with +the shared catalog IDs, independently of per-choice expected names. Keep Grok +Build's coding reason, supplied catalog order and current-name regression. `browser/first-use-guide.spec.ts` covers keyboard focus, both themes, large-small-large viewport changes, real click reachability, persistence and positive catalog copy in Chromium/WebKit. Run the existing directory/browser regressions and production boot gate. WebKit keyboard tests use its Option-Tab all-controls navigation, without changing the host keyboard-access setting. +The four-item coding result must keep recommendations and completion/skip +controls reachable at the smallest supported viewport. Browser fixtures do not prove native first-install or Windows/macOS installer behavior. The production navigation smoke test also verifies that a fresh user loads the diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/check.jsonl b/.trellis/tasks/09-15-grok-guide-recommendation/check.jsonl new file mode 100644 index 000000000..bcaf4cf2f --- /dev/null +++ b/.trellis/tasks/09-15-grok-guide-recommendation/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/frontend/first-use-guide.md", "reason": "防遗漏、名称顺序与重启行为"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试和生产构建证据边界"} diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl b/.trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl new file mode 100644 index 000000000..0b5d46a78 --- /dev/null +++ b/.trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/frontend/first-use-guide.md", "reason": "用途推荐、目录交集和跳过边界"} +{"file": ".trellis/spec/frontend/user-facing-copy.md", "reason": "简短正向用途说明"} diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/implement.md b/.trellis/tasks/09-15-grok-guide-recommendation/implement.md new file mode 100644 index 000000000..9831fcaee --- /dev/null +++ b/.trellis/tasks/09-15-grok-guide-recommendation/implement.md @@ -0,0 +1,52 @@ +# Correction plan and evidence + +## Change boundary + +- 行为缺口位于 `src/pages/agents/firstUseRecommendations.ts` 的静态用途关联和说明,不在原生目录。 +- 仅增加 `grokbuild` 的 coding 关联和简短用途说明;不改混合用途的精选集合。 +- 修改 `tests/renderer/pages/agents/Page.test.tsx`,复用已有目录夹具,补齐全目录覆盖、Grok 说明与目录顺序检查。 +- 修改 `tests/browser/first-use-guide.spec.ts`,更新编程预期并检查四个推荐卡片及跳过/完成控件可见可操作。 +- 更新 `.trellis/spec/frontend/first-use-guide.md` 的编程集合与防遗漏测试要求。 +- 不做重构,不新增依赖或跨层接口。任务由主会话执行,当前 DevSpace 工具不提供子代理派发接口。 + +## Research and review + +2026-09-15 已核对官方定位: + +- https://docs.x.ai/build/overview — Grok Build 为 coding agent,可用交互式 TUI。 +- https://x.ai/build — 代码搜索、多文件编辑、测试与终端执行属于产品定位。 + +这些资料只支持用途说明,不改变 FyAgent 安装或配置能力。 +根因是初始推荐名单遗漏 Grok Build,测试也复制了不完整的三项名单,没有独立覆盖目录。 + +## Plan + +- [x] 先添加回归并确认旧实现会失败。 +- [x] 最小修复推荐与说明,验证四项推荐与完整目录覆盖。 +- [x] 串行运行完整归档前检查、浏览器回归、生产构建启动检查。 +- [x] 更新 SPEC、记录真实结果并完成最终评审;提交/归档状态以 task.json 和 Git 为准。 + +## Evidence boundary + +只验证当前 macOS 宿主上的自动化代码与浏览器行为,不代表真机全新安装验收。 +既有用户不会为了推荐名单纠错被重新弹出首次引导。 + +## Verification notes + +- 新增的目录覆盖和 Grok 名称/顺序两项测试在旧实现上均失败,集合差异明确为 `grokbuild` 缺失。 +- 最小两行数据修复后,聚焦页面、端口和 ACL 测试 49/49 通过。 +- 浏览器新增检查的首版误把“可滚动访问”写成“全列表同时在视口中”,未改产品布局,改为逐项滚动检查。 +- `scrollIntoViewIfNeeded` 的边缘定位随后出现 0.983357/0.994792 的交叉比例;改为逐项居中滚动,保留 `ratio: 1` 完整可见断言和真实点击准入检查,不降低阈值。900×600 的编程推荐复验已通过。 +- SPEC 已更新编程四项集合、单用途并集覆盖与小窗口检查要求;不改原生状态或原任务历史材料。 +- 首次引导全尺寸 Chromium/WebKit 复验 30/30 通过。 +- `TRELLIS_CONTEXT_ID=fyagent-grok-guide-correction mise run check:prearchive --exclude-active-task .trellis/tasks/09-15-grok-guide-recommendation` 完整退出码 0,覆盖类型、Lint、格式、前端/契约单测、桌面 mock、Rust 格式/check/Clippy/测试和发布契约;发布子集另显示 619 通过、1 项既有跳过,native-fetch 4/4 通过。不把重叠子集累计为新增测试量。 +- 最终 `mise run build:renderer` 通过,八个路由独立分包;初始 JS 665004 字节、CSS 46664 字节,与本次修复前一致,未调高预算。 +- 最终生产 `production boots` 3/3 通过,覆盖八路由初始化、引导按需加载/跳过和动效时间单位。 +- 最终目录、模型和引导浏览器回归 90/90 通过,四档 Chromium 窗口与 WebKit;包含之前 30 项引导测试,不重复累计。 +- 最终差异复核仅推荐数据、测试、owner SPEC 和本任务记录;原生能力、首次判定、普通用户数据均未修改。 + +## Commit plan + +一笔 `fix(agents): include Grok Build in first-use recommendations`,包含推荐数据、页面/浏览器回归、owner SPEC 和本任务材料。 +仅暂存本任务已知文件,不含其他工作;之后由任务工具生成归档与会话记录提交,不推送远端。 +归档后运行不带 active-task 排除的 `mise run check:contracts`,结果记录到收尾会话日志。 diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/prd.md b/.trellis/tasks/09-15-grok-guide-recommendation/prd.md new file mode 100644 index 000000000..731f4d50d --- /dev/null +++ b/.trellis/tasks/09-15-grok-guide-recommendation/prd.md @@ -0,0 +1,30 @@ +# 补齐首次引导的 Grok Build 编程推荐 + +## Goal + +修正用户指出的 Grok Build 推荐遗漏,完成原首次使用引导交付的纠错。 + +## Background + +原任务已归档,但首次引导的编程推荐只包含 Codex、Claude Code、OpenCode。 +Grok Build 在七款软件主目录中存在,却没有出现在任何用途推荐中;原设计没有给出排除依据。 +本任务承接用户原先要求的实现、验证、更新 SPEC 后归档,不增加独立产品特性。 + +## Requirements + +- R1:选择编程开发时包含 Grok Build 及其简短、正向的编程用途说明。 +- R2:保留原有三款编程软件,沿用实时目录的名称和顺序,不展示目录中不存在的软件。 +- R3:办公及混合用途的精简推荐、跳过与持久化行为不变;推荐不触发安装或配置写入。 +- R4:补充防遗漏回归,更新首次引导 SPEC 后完成归档。 + +## Acceptance Criteria + +- AC1:编程推荐依次展示 Grok Build、Codex、Claude Code、OpenCode。 +- AC2:当前七款目录软件均至少被一个单用途推荐覆盖;新目录项目不能静默遗漏。 +- AC3:界面测试验证四项推荐、保存失败后的保留、跳过与完成;四项推荐在最小支持窗口内可操作。 +- AC4:聚焦测试、完整归档前检查、浏览器与生产构建验证通过,证据记录后更新 SPEC 并归档。 + +## Out of Scope + +不改主目录、原生能力与权限、首次安装判定、安装/登录流程、依赖和其他用途推荐。 +不重写原任务的历史验收记录,不推送远端。 diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/task.json b/.trellis/tasks/09-15-grok-guide-recommendation/task.json new file mode 100644 index 000000000..4eb0d1a65 --- /dev/null +++ b/.trellis/tasks/09-15-grok-guide-recommendation/task.json @@ -0,0 +1,31 @@ +{ + "id": "grok-guide-recommendation", + "name": "grok-guide-recommendation", + "title": "补齐首次引导的 Grok Build 编程推荐", + "description": "修正首次软件推荐遗漏 Grok Build,补充目录覆盖与四项推荐回归,更新 SPEC 后归档。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pythonrust", + "assignee": "pythonrust", + "createdAt": "2026-09-15", + "completedAt": null, + "branch": "dev/laiyongjie", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "src/pages/agents/firstUseRecommendations.ts", + "tests/renderer/pages/agents/Page.test.tsx", + "tests/browser/first-use-guide.spec.ts", + ".trellis/spec/frontend/first-use-guide.md" + ], + "notes": "R1–R4 已实现并评审。聚焦 49/49、最终浏览器 90/90、生产启动 3/3 和完整归档前检查通过;SPEC 已更新,初始包预算不变。无原生状态修改,无真机全新安装验收,不推送远端。", + "meta": {} +} diff --git a/src/pages/agents/firstUseRecommendations.ts b/src/pages/agents/firstUseRecommendations.ts index 7b1153dc3..ff8a744f7 100644 --- a/src/pages/agents/firstUseRecommendations.ts +++ b/src/pages/agents/firstUseRecommendations.ts @@ -17,7 +17,7 @@ export const GUIDE_PURPOSES: readonly { const recommendedIds: Record = { office: ["qoderwork", "trae-work", "workbuddy"], - coding: ["codex", "claude-code", "opencode"], + coding: ["grokbuild", "codex", "claude-code", "opencode"], both: ["workbuddy", "codex"], }; @@ -25,6 +25,7 @@ const reasons: Partial> = { qoderwork: "整理文件、处理数据与生成文档", "trae-work": "文档、演示稿与资料调研", workbuddy: "处理日常办公任务", + grokbuild: "在终端中编写代码与运行测试", codex: "开发功能、修复问题与检查代码", "claude-code": "在终端中理解、修改和测试代码", opencode: "在桌面或终端中编写代码", diff --git a/tests/browser/first-use-guide.spec.ts b/tests/browser/first-use-guide.spec.ts index 1c2e7e715..8463c2d09 100644 --- a/tests/browser/first-use-guide.spec.ts +++ b/tests/browser/first-use-guide.spec.ts @@ -13,7 +13,10 @@ import { for (const choice of [ { label: "日常办公", names: ["QoderWork CN", "TRAE Work CN", "WorkBuddy"] }, - { label: "编程开发", names: ["Codex", "Claude Code", "OpenCode"] }, + { + label: "编程开发", + names: ["Grok Build", "Codex", "Claude Code", "OpenCode"], + }, { label: "两者都用", names: ["WorkBuddy", "Codex"] }, ]) { test(`first use recommends ${choice.label} and persists completion`, async ({ @@ -34,6 +37,28 @@ for (const choice of [ await expect(guide.getByRole("heading", { level: 2 })).toHaveText( choice.names, ); + await expectNoHorizontalOverflow(page); + const complete = guide.getByRole("button", { name: "查看全部软件" }); + const skip = guide.getByRole("button", { name: "跳过引导" }); + // Center scrolled content instead of leaving it on a fractional clip edge. + for (const item of [ + ...choice.names.map((name) => + guide.getByRole("heading", { name, exact: true }), + ), + complete, + skip, + ]) { + await item.evaluate((node) => + node.scrollIntoView({ + block: "center", + inline: "nearest", + behavior: "instant", + }), + ); + await expect(item).toBeInViewport({ ratio: 1 }); + } + await complete.click({ trial: true }); + await skip.click({ trial: true }); const calls = await featureFixtureCalls(page); expect(calls.map((call) => call.command)).not.toContain( "get_agent_install_readiness", diff --git a/tests/renderer/pages/agents/Page.test.tsx b/tests/renderer/pages/agents/Page.test.tsx index fcbc473f9..bbdf2d846 100644 --- a/tests/renderer/pages/agents/Page.test.tsx +++ b/tests/renderer/pages/agents/Page.test.tsx @@ -449,7 +449,7 @@ describe("first-use software guide", () => { it.each([ ["日常办公", ["QoderWork CN", "TRAE Work CN", "WorkBuddy"]], - ["编程开发", ["Codex", "Claude Code", "OpenCode"]], + ["编程开发", ["Grok Build", "Codex", "Claude Code", "OpenCode"]], ["两者都用", ["WorkBuddy", "Codex"]], ])( "recommends catalog entries for %s without side effects", @@ -524,7 +524,7 @@ describe("first-use software guide", () => { await screen.findByText("暂时无法保存引导状态,请重试。"), ).toBeVisible(); expect(screen.queryByText("private native path")).not.toBeInTheDocument(); - expect(screen.getAllByRole("article")).toHaveLength(3); + expect(screen.getAllByRole("article")).toHaveLength(4); expect(ports.agentInstallReadiness.get).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "查看全部软件" })); await screen.findByRole("heading", { name: "我的 AI 软件" }); @@ -656,6 +656,32 @@ describe("first-use software guide", () => { }, ); + it("covers every current catalog identity across the office and coding recommendations", () => { + const entries = catalog().agents; + expect(entries.map((item) => item.id)).toEqual([...AGENT_CATALOG_IDS]); + const recommendations = [ + ...firstUseRecommendations(entries, "office"), + ...firstUseRecommendations(entries, "coding"), + ]; + expect(new Set(recommendations.map((item) => item.entry.id))).toEqual( + new Set(AGENT_CATALOG_IDS), + ); + for (const recommendation of recommendations) { + expect(recommendation.reason.trim()).not.toBe(""); + expect(recommendation.reason).not.toBe(recommendation.entry.description); + } + }); + + it("keeps Grok Build coding recommendations in supplied catalog order with current names", () => { + const codex = entry("codex", "Current Codex name"); + const grok = entry("grokbuild", "Current Grok Build name"); + expect(firstUseRecommendations([codex, grok], "coding")).toEqual([ + { entry: codex, reason: "开发功能、修复问题与检查代码" }, + { entry: grok, reason: "在终端中编写代码与运行测试" }, + ]); + expect(firstUseRecommendations([grok], "office")).toEqual([]); + }); + it("only recommends supplied catalog identities and uses their current names", () => { const renamed = { ...entry("codex", "Current catalog name") }; expect(firstUseRecommendations([renamed], "coding")).toEqual([ From c5a6cfbf6c60f21ea9de1f4e57b568bdd76be9cb Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:44:42 +0800 Subject: [PATCH 5/9] chore(task): archive 09-15-grok-guide-recommendation --- .../2026-09}/09-15-grok-guide-recommendation/check.jsonl | 0 .../09-15-grok-guide-recommendation/implement.jsonl | 0 .../2026-09}/09-15-grok-guide-recommendation/implement.md | 0 .../2026-09}/09-15-grok-guide-recommendation/prd.md | 0 .../2026-09}/09-15-grok-guide-recommendation/task.json | 8 ++++---- 5 files changed, 4 insertions(+), 4 deletions(-) rename .trellis/tasks/{ => archive/2026-09}/09-15-grok-guide-recommendation/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-grok-guide-recommendation/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-grok-guide-recommendation/implement.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-grok-guide-recommendation/prd.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-grok-guide-recommendation/task.json (90%) diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/check.jsonl b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/check.jsonl similarity index 100% rename from .trellis/tasks/09-15-grok-guide-recommendation/check.jsonl rename to .trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/check.jsonl diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/implement.jsonl similarity index 100% rename from .trellis/tasks/09-15-grok-guide-recommendation/implement.jsonl rename to .trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/implement.jsonl diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/implement.md b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/implement.md similarity index 100% rename from .trellis/tasks/09-15-grok-guide-recommendation/implement.md rename to .trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/implement.md diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/prd.md b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/prd.md similarity index 100% rename from .trellis/tasks/09-15-grok-guide-recommendation/prd.md rename to .trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/prd.md diff --git a/.trellis/tasks/09-15-grok-guide-recommendation/task.json b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/task.json similarity index 90% rename from .trellis/tasks/09-15-grok-guide-recommendation/task.json rename to .trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/task.json index 4eb0d1a65..cbcf97118 100644 --- a/.trellis/tasks/09-15-grok-guide-recommendation/task.json +++ b/.trellis/tasks/archive/2026-09/09-15-grok-guide-recommendation/task.json @@ -3,7 +3,7 @@ "name": "grok-guide-recommendation", "title": "补齐首次引导的 Grok Build 编程推荐", "description": "修正首次软件推荐遗漏 Grok Build,补充目录覆盖与四项推荐回归,更新 SPEC 后归档。", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,11 +11,11 @@ "creator": "pythonrust", "assignee": "pythonrust", "createdAt": "2026-09-15", - "completedAt": null, + "completedAt": "2026-09-15", "branch": "dev/laiyongjie", "base_branch": "main", "worktree_path": null, - "commit": null, + "commit": "4eb7e346b88b9386710d2b21e5769d8b4d2d3ff7", "pr_url": null, "subtasks": [], "children": [], @@ -28,4 +28,4 @@ ], "notes": "R1–R4 已实现并评审。聚焦 49/49、最终浏览器 90/90、生产启动 3/3 和完整归档前检查通过;SPEC 已更新,初始包预算不变。无原生状态修改,无真机全新安装验收,不推送远端。", "meta": {} -} +} \ No newline at end of file From 5b35e69d9a4639ffdf3b3d6a2b22cef2251f1d78 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:45:48 +0800 Subject: [PATCH 6/9] chore: record journal --- .trellis/workspace/pythonrust/index.md | 5 +++-- .trellis/workspace/pythonrust/journal-2.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/pythonrust/index.md b/.trellis/workspace/pythonrust/index.md index 0b0fc1d10..80b001905 100644 --- a/.trellis/workspace/pythonrust/index.md +++ b/.trellis/workspace/pythonrust/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-2.md` -- **Total Sessions**: 91 +- **Total Sessions**: 92 - **Last Active**: 2026-09-15 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-2.md` | ~883 | Active | +| `journal-2.md` | ~905 | Active | | `journal-1.md` | ~1987 | Archived | @@ -30,6 +30,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 92 | 2026-09-15 | 补齐 Grok Build 首次推荐并防遗漏 | `4eb7e346b88b9386710d2b21e5769d8b4d2d3ff7` | `dev/laiyongjie` | | 91 | 2026-09-15 | AI 软件正向文案与可跳过的首次推荐引导 | `ae1da25a125d23ca7823d3ebc4ac570fd662d020` | `dev/laiyongjie` | | 90 | 2026-09-14 | Stabilize merge-queue contrast sampling | `5be5540c` | `dev/laiyongjie` | | 89 | 2026-09-14 | Audit ahead-of-main commits and split SPEC owners | `20dd6537` | `dev/laiyongjie` | diff --git a/.trellis/workspace/pythonrust/journal-2.md b/.trellis/workspace/pythonrust/journal-2.md index c6928035d..ac5274757 100644 --- a/.trellis/workspace/pythonrust/journal-2.md +++ b/.trellis/workspace/pythonrust/journal-2.md @@ -881,3 +881,25 @@ Diagnosed PR #188 merge-group WebKit failure as a two-phase raster sampling race ### Status [OK] **Completed** + + +## Session 92: 补齐 Grok Build 首次推荐并防遗漏 + + +**Date**: 2026-09-15 +**Task**: 补齐 Grok Build 首次推荐并防遗漏 +**Branch**: `dev/laiyongjie` + +### Summary + +修正首次引导编程推荐遗漏 Grok Build,加入简短用途说明;目录七项覆盖与 Grok 当前名称/顺序回归先红后绿。聚焦 49/49、最终浏览器 90/90、生产启动 3/3、完整 prearchive 与归档后不带排除的 check:contracts 均通过。首屏 JS 665004 字节不变,SPEC 更新后已归档 09-15-grok-guide-recommendation。未改原生首次判定、未做真机全新安装验收、未推送远端。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `4eb7e346b88b9386710d2b21e5769d8b4d2d3ff7` | fix(agents): include Grok Build in first-use recommendations | + +### Status + +[OK] **Completed** From ebcffdee5ed0684c6364f74922038c10e5f39a76 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:06:26 +0800 Subject: [PATCH 7/9] fix(agents): enforce first-use guide ownership --- .../backend/external-agent-catalog-runtime.md | 2 + .trellis/spec/backend/first-use-guide.md | 73 ++++++++++++------- .trellis/spec/frontend/first-use-guide.md | 71 +++++++++++------- .../check.jsonl | 5 ++ .../design.md | 57 +++++++++++++++ .../implement.jsonl | 6 ++ .../implement.md | 60 +++++++++++++++ .../prd.md | 60 +++++++++++++++ .../task.json | 26 +++++++ src-tauri/src/commands/settings.rs | 59 ++++++++++----- src-tauri/src/settings/first_use_guide.rs | 41 ++++++++++- src/pages/agents/firstUseRecommendations.ts | 4 +- 12 files changed, 388 insertions(+), 76 deletions(-) create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/design.md create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/implement.md create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/prd.md create mode 100644 .trellis/tasks/09-15-review-todays-specs-and-merge/task.json diff --git a/.trellis/spec/backend/external-agent-catalog-runtime.md b/.trellis/spec/backend/external-agent-catalog-runtime.md index 4794f4263..6dcca5cb8 100644 --- a/.trellis/spec/backend/external-agent-catalog-runtime.md +++ b/.trellis/spec/backend/external-agent-catalog-runtime.md @@ -192,6 +192,8 @@ Required assertion points: - exact contract version, product order, capability order, link IDs and closed enums in Rust and `src/shared/features/agents.ts`; +- every directory description remains positive supported-capability prose while + the exact capability modes/reasons stay unchanged and independently asserted; - `EXPECTED_AGENT_LINK_IDS` matches the native v5 table; Claude Desktop and Claude CLI+Desktop payloads fail closed; - unknown/excess fields, duplicate IDs and legacy/future versions fail closed; diff --git a/.trellis/spec/backend/first-use-guide.md b/.trellis/spec/backend/first-use-guide.md index f5b3882d3..8e05e1cb8 100644 --- a/.trellis/spec/backend/first-use-guide.md +++ b/.trellis/spec/backend/first-use-guide.md @@ -12,6 +12,7 @@ authentication, model configuration, telemetry or a database migration. ```text settings.json: firstUseGuideState?: "pending" | "dismissed" +settings.json: firstRunNoticeConfirmed?: boolean # legacy compatibility get_first_use_guide_state() -> "pending" | "dismissed" dismiss_first_use_guide() -> Result<"dismissed", String> ``` @@ -23,57 +24,73 @@ is an error rather than successful completion. ## 3. Contracts +### Admission and restart authority + - Initialize pending before database creation/seeding, only when the database, legacy `config.json` and device `settings.json` are confirmed absent. An - existence-check error is not absence. Existing settings, even malformed, - must not be overwritten just to initialize this guide. + existence-check error is not absence. A present, malformed or unreadable + settings file is an existing/unknown installation and must not be overwritten + just to initialize this guide. - No marker on an existing installation means dismissed. Do not infer first install from an empty providers table, a missing renderer localStorage key, application version or the absence of detected third-party software. - Existing pending survives restart; dismissed and legacy `firstRunNoticeConfirmed: true` suppress the guide. Closing the app without finishing or skipping does not manufacture user acknowledgement. -- Dismissal uses the existing settings write lock and persistence owner to - merge only `firstUseGuideState: dismissed` and - `firstRunNoticeConfirmed: true`. Persist before changing the in-memory value. -- Ordinary `save_settings` preserves the native-owned guide marker and a true - legacy acknowledgement against stale renderer snapshots. Purpose choice is - not persisted or transmitted. - The device file, not the synced database or renderer cache, is the restart authority. Removing all local application data creates a new installation context; replacing the app binary while retaining data does not. +### Mutation authority + +- Dismissal uses the existing settings write lock and persistence owner to + merge only `firstUseGuideState: dismissed` and + `firstRunNoticeConfirmed: true`. Persist before changing the in-memory value. +- Both fields are native-owned for this workflow. Ordinary `save_settings` + preserves their latest locked values exactly: a stale payload cannot reopen + dismissed state, and an arbitrary payload cannot dismiss pending state through + either the current marker or the legacy acknowledgement. Returning those + fields in a compatibility settings snapshot does not grant write authority. +- `dismiss_first_use_guide` is the only Renderer-reachable transition from + pending to dismissed. It is idempotent for an already dismissed installation. + Purpose choice is component-local and is never persisted or transmitted. + ## 4. Validation & Error Matrix -| Condition | Result | -| --- | --- | -| Confirmed new local data, no acknowledgement | Persist pending before DB initialization. | -| Existing DB, JSON or settings and no marker | Return dismissed without onboarding initialization writes. | -| Pending with DB now present | Continue pending. | -| Dismissed or legacy confirmed | Do not reopen. | -| Initialization persistence failure | Log safely; do not claim pending was saved. | -| Dismissal persistence failure | Return failure and retain prior in-memory state. | -| Stale ordinary settings save | Preserve guide completion. | -| Unknown wire state or pending dismissal acknowledgement | Reject at renderer adapter. | +| Condition | Result | +| ------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Confirmed new local data, no acknowledgement | Persist pending before DB initialization. | +| Existing DB, JSON or settings and no marker | Return dismissed without onboarding initialization writes. | +| Settings exists but is malformed/unreadable, or any existence check fails | Treat as existing/unknown; do not create pending. | +| Pending with DB now present | Continue pending. | +| Dismissed or legacy confirmed | Do not reopen. | +| Initialization persistence failure | Log safely; do not claim pending was saved. | +| Dismissal persistence failure | Return failure and retain prior in-memory state. | +| Ordinary settings payload changes either first-use field | Ignore both incoming values and preserve the latest locked pair. | +| Unknown wire state or pending dismissal acknowledgement | Reject at renderer adapter. | ## 5. Good / Base / Bad Cases Good: a completed guide remains dismissed after a settings panel saves an old -snapshot. Base: an interrupted first launch resumes its pending guide. Bad: +snapshot, while a pending guide cannot be acknowledged through that same generic +save path. Base: an interrupted first launch resumes its pending guide. Bad: checking providers after built-in providers were seeded or submitting the entire renderer settings object merely to dismiss onboarding. ## 6. Tests Required -`settings/first_use_guide.rs` tests cover eligibility, old settings, legacy -acknowledgement, closed serialization and pending restart. The commands/settings -merge test protects completion from stale saves. Renderer port and ACL tests -cover exact no-argument calls, response rejection and registration/permission -closure. Run Rust format/check/Clippy/tests and renderer type/lint/unit gates. -Browser persistence fixtures are mock evidence, not fresh-install HIL evidence. +`settings/first_use_guide.rs` tests cover the all-absence admission matrix, +existing markers, legacy acknowledgement, closed serialization and pending +restart. The commands/settings merge test protects both directions: stale input +cannot reopen completion or acknowledge pending state. Renderer port and ACL +tests cover exact no-argument calls, response rejection and +registration/permission closure. Run Rust format/check/Clippy/tests and Renderer +type/lint/unit gates. Browser persistence fixtures are mock evidence, not +fresh-install HIL evidence. ## 7. Wrong vs Correct -Wrong: `save_settings({ ...cachedSettings, firstRunNoticeConfirmed: true })`. -Correct: `dismiss_first_use_guide()` merges the two native-owned fields against -the latest locked settings and acknowledges only after persistence. +Wrong: `save_settings({ ...cachedSettings, firstRunNoticeConfirmed: true })` or +trusting an incoming `firstUseGuideState`. Correct: ordinary settings saves copy +both fields from the latest locked state; `dismiss_first_use_guide()` alone +merges the completed pair and acknowledges only after persistence. diff --git a/.trellis/spec/frontend/first-use-guide.md b/.trellis/spec/frontend/first-use-guide.md index 9245be542..1c22c865b 100644 --- a/.trellis/spec/frontend/first-use-guide.md +++ b/.trellis/spec/frontend/first-use-guide.md @@ -23,12 +23,18 @@ rejects native-only dismissal instead of pretending to save. ## 3. Contracts +### Route and startup + - Only the directory branch may show onboarding. A valid explicit `target` - remains authoritative. Query activity follows persistent route visibility. + remains authoritative and disables the first-use query. Query activity follows + persistent route visibility. - Wait for catalog and guide-state settlement before presenting the directory or guide and acknowledging frontend-ready. Do not use RAF/document visibility for native startup readiness. A failed guide read opens the ordinary directory, not an invented fresh-install state. +- A catalog error, empty catalog or target/catalog mismatch keeps the existing + directory error/empty/recovery surface and its ready acknowledgement. Do not + mount an incomplete guide or write dismissal without a valid parsed catalog. - Lazy-load the one-time guide and its CSS only for a pending user. The committed guide component owns frontend-ready while its chunk is loading; a Suspense placeholder must not reveal the native window. Catalog error/empty states keep @@ -37,6 +43,9 @@ rejects native-only dismissal instead of pretending to save. Audit the static dependency closure as well as page chunks: a hook first used in a lazy page can still grow a bootstrap-shared vendor chunk. Keep this narrow write consistent with the existing guarded local-operation pattern. + +### Recommendation projection + - The question is one sentence, with office, coding and combined-use options. Both steps expose skip. Selection shows a small set of recommendations, with back/reselect and a full-directory action; it is not an installation wizard. @@ -47,13 +56,21 @@ rejects native-only dismissal instead of pretending to save. Intersect IDs with the parsed catalog and preserve its names/order. Do not create a fallback catalog or infer installation/action permissions. For the current seven products, the office/coding union covers the complete - catalog, with a concise purpose description for each. Cross-check against - `AGENT_CATALOG_IDS`; do not silently omit a product by testing only a copied - shortlist. Future deliberate exclusions require a recorded product rationale. - Combined use remains a curated entry point, not the full catalog union. + catalog, with a concise purpose description for each. The purpose-description + map is exhaustive over the closed `AgentCatalogId`; never fall back to a + generic catalog description when a reason is missing. Cross-check identities + against `AGENT_CATALOG_IDS`; do not silently omit a product by testing only a + copied shortlist. A future new identity must receive an explicit description, + while any deliberate exclusion from both single-purpose sets requires a + recorded product rationale. Combined use remains a curated entry point, not + the full catalog union. - Purpose is component-local. Choosing it performs no native write, auth, - install, model change or telemetry. Directory scanning starts only after the - first-use check settles with no guide or dismissal succeeds. + install, model change or telemetry. + +### Completion and lifecycle + +- Directory scanning starts only after the first-use check settles with no guide + or after dismissal succeeds. Never start it behind the guide. - Completion and skip use the same narrow native dismissal. Keep the current step on failure with a safe retry message; never display raw native errors. A synchronous admission guard and disabled controls prevent duplicate writes. @@ -64,16 +81,17 @@ rejects native-only dismissal instead of pretending to save. ## 4. Validation & Error Matrix -| Condition | UI behavior | -| --- | --- | -| Pending and valid catalog on directory | Show purpose question, not a directory flash. | -| Dismissed / old installation | Ordinary directory. | -| Explicit software target | Existing configuration view, no guide interception. | -| Guide read fails | Ordinary directory; no acknowledgement write. | -| Save pending | Keep guide; disable duplicate actions and choices. | -| Save fails | Keep current recommendations; allow retry. | -| Save succeeds | Full directory; no repeat with a fresh query client. | -| Route hidden while save finishes | Update cache without focus or scan work. | +| Condition | UI behavior | +| ----------------------------------------------------- | ------------------------------------------------------------------- | +| Pending and valid catalog on directory | Show purpose question, not a directory flash. | +| Dismissed / old installation | Ordinary directory. | +| Explicit software target | Existing configuration view, no guide interception. | +| Guide read fails | Ordinary directory; no acknowledgement write. | +| Catalog fails, is empty, or cannot resolve the target | Existing catalog error/empty/recovery UI; no guide acknowledgement. | +| Save pending | Keep guide; disable duplicate actions and choices. | +| Save fails | Keep current recommendations; allow retry. | +| Save succeeds | Full directory; no repeat with a fresh query client. | +| Route hidden while save finishes | Update cache without focus or scan work. | ## 5. Good / Base / Bad Cases @@ -89,7 +107,9 @@ completion, new query-client restart, delayed/failed persistence, duplicate clicks, unknown startup reads, target links and hidden completion. Native-port tests reject unknown states and pending write acknowledgements. The office/coding coverage assertion compares recommendation identities with -the shared catalog IDs, independently of per-choice expected names. Keep Grok +the shared catalog IDs, independently of per-choice expected names. Typecheck +must reject a new closed catalog identity without an explicit purpose reason; +tests also reject using the generic entry description as that reason. Keep Grok Build's coding reason, supplied catalog order and current-name regression. `browser/first-use-guide.spec.ts` covers keyboard focus, both themes, large-small-large viewport changes, real click reachability, persistence and @@ -97,15 +117,16 @@ positive catalog copy in Chromium/WebKit. Run the existing directory/browser regressions and production boot gate. WebKit keyboard tests use its Option-Tab all-controls navigation, without changing the host keyboard-access setting. The four-item coding result must keep recommendations and completion/skip -controls reachable at the smallest supported viewport. -Browser fixtures do not prove native -first-install or Windows/macOS installer behavior. +controls reachable at the smallest supported viewport. Browser fixtures do not +prove native first-install or Windows/macOS installer behavior. The production navigation smoke test also verifies that a fresh user loads the guide chunk and a post-skip reload does not request it. ## 7. Wrong vs Correct -Wrong: `if (!localStorage.getItem("welcome")) showGuide()` or hiding unsupported -actions by rewriting their runtime status. Correct: native first-use state owns -eligibility; only catalog summary prose is positive-only, while actual operation -errors, unknown states and security confirmations stay visible. +Wrong: `if (!localStorage.getItem("welcome")) showGuide()`, using +`reason ?? entry.description`, or hiding unsupported actions by rewriting their +runtime status. Correct: native first-use state owns eligibility; every closed +catalog identity has an explicit purpose reason; only catalog summary prose is +positive-only, while actual operation errors, unknown states and security +confirmations stay visible. diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl b/.trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl new file mode 100644 index 000000000..191b3d72e --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/backend/first-use-guide.md","reason":"检查资格失败矩阵、双字段唯一写权限和 required tests 与实现一致。"} +{"file":".trellis/spec/frontend/first-use-guide.md","reason":"检查三组推荐、目录交集、错误/隐藏行为和生产分包测试契约。"} +{"file":".trellis/spec/backend/github-ci-workflow.md","reason":"检查 PR/merge-group 域调度、稳定 Required 结果和 CI 证据层级。"} +{"file":".trellis/spec/backend/github-merge-governance.md","reason":"检查归档、推送、exact-head 自动合并、队列和 post-merge 读回。"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"检查单测、浏览器、生产构建和证据边界是否与主张匹配。"} diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/design.md b/.trellis/tasks/09-15-review-todays-specs-and-merge/design.md new file mode 100644 index 000000000..7ecbc821d --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/design.md @@ -0,0 +1,57 @@ +# Design + +## Review boundary + +本次评审沿完整数据流进行: + +```text +本地数据存在性 -> settings 原生状态 -> 无参数 Tauri 命令 -> 严格端口解析 +-> query cache -> /agents 路由门禁 -> 两步引导 -> 原生持久化 -> 目录扫描 +``` + +保留两个聚焦 owner:后端 SPEC 负责资格、持久化、命令与普通设置防覆盖;前端 +SPEC 负责路由、查询、推荐投影、交互生命周期与可访问性。目录、目录文案、窗口 +就绪和 GitHub 治理文档只描述与 owner 的交界,不复制状态机。当前 owner 文档均 +足够聚焦且低于上下文限制,因此不为形式拆分。 + +## Native state authority + +`firstUseGuideState` 和 `firstRunNoticeConfirmed` 都视为原生维护字段。专用完成命令 +在同一个设置写锁内将二者写为完成值;普通 `save_settings` 无论入参携带什么值, +都从锁内最新设置原样恢复这两个字段。这样同时阻止旧快照重新打开已完成引导, +也阻止任意全量设置调用借旧兼容字段跳过待完成引导。 + +资格判定继续在数据库创建之前执行。数据库、旧 `config.json` 和设备设置文件三者 +必须全部得到“确认不存在”的证据;文件存在、解析失败、权限错误或 `try_exists` +失败均 fail closed。状态写入成功后才更新进程内值,写失败不制造 `pending`。 + +## Recommendation projection + +用途关联继续是页面局部静态集合,并与严格解析后的原生目录取交集。用途说明改为 +对封闭 `AgentCatalogId` 的穷尽 `Record`,移除目录简介兜底。未来新增目录身份时, +类型检查会要求明确说明;是否加入某用途仍由覆盖回归和产品评审决定。 + +不改变当前推荐集合、目录名称来源、排序或任何安装/配置权限。 + +## SPEC convergence + +- 后端 owner 补充双字段写权限、存在性失败矩阵、双向 stale-save 回归和唯一合法 + 状态转换。 +- 前端 owner 按路由/启动、推荐投影、完成生命周期分组,补充无有效目录时不得 + 写确认以及说明映射必须穷尽、不得兜底。 +- catalog 和 user-facing-copy 文档保留正向摘要与真实运行错误的边界,不承载首次 + 状态。 +- 一次性提交 SHA、测试数量、CI run ID 与最终 PR 证据只写本任务,不写稳定 SPEC。 + +## Git and delivery + +`origin/main` 当前只比本分支多上一轮 PR 的 merge commit;该 merge commit 已包含 +本分支共同祖先,不需要为了“保持最新”重写六个已评审提交。Merge Queue 是最新 +base 集成权威。完成本地生命周期后推送当前分支,以精确 head guard 开启自动合并; +若 PR 或 merge-group CI 暴露问题,在同一分支最小修复、重跑适用门禁并更新精确 +head,再重新进入队列。 + +## Rollback + +本次代码修正仅收紧普通设置写权限与推荐说明类型,不改变持久化格式或 IPC 签名。 +回滚对应提交即可恢复原行为;已有 `pending` / `dismissed` 文件仍可被前后版本读取。 diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl b/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl new file mode 100644 index 000000000..ec691e0d9 --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"复核设备设置、Rust IPC、Renderer 查询与 UI 的完整跨层数据流和 authority。"} +{"file":".trellis/spec/backend/first-use-guide.md","reason":"首次安装资格、设备持久化、窄命令和普通设置防覆盖的后端 owner。"} +{"file":".trellis/spec/frontend/first-use-guide.md","reason":"路由门禁、推荐投影、完成生命周期、可访问性与浏览器证据的前端 owner。"} +{"file":".trellis/spec/backend/external-agent-catalog-runtime.md","reason":"原生七项目录身份、顺序和正向摘要边界。"} +{"file":".trellis/spec/frontend/user-facing-copy.md","reason":"正向目录摘要与真实错误、未知和安全提示的文案边界。"} +{"file":".trellis/spec/backend/github-merge-governance.md","reason":"Trellis 归档、精确 PR head、Merge Queue 和最终 main 读回顺序。"} diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.md b/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.md new file mode 100644 index 000000000..08ab6fc3e --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.md @@ -0,0 +1,60 @@ +# Implementation and validation plan + +## Plan + +- [x] 复核六个提交及相关实现、测试、SPEC 和索引,记录 owner 与证据边界。 +- [x] 收紧普通设置合并:原样保留两个首次引导原生字段,并添加双向防覆盖回归。 +- [x] 将推荐说明改为封闭目录身份的穷尽映射,移除通用简介兜底。 +- [x] 收敛后端/前端 first-use owner SPEC 和必要的相邻发现入口;不改历史归档任务。 +- [x] 运行格式、聚焦 Rust/Renderer 测试、SPEC/task context 校验和完整归档前门禁。 +- [x] 复核最终 diff、包体/浏览器证据需要与未验证边界,提交产品与任务变更。 + +归档后的 GitHub 交付按治理顺序执行:先运行归档后契约检查并确认工作树、base +drift 与精确 head,再推送分支、创建 PR、启用 exact-head auto-merge,并跟进 PR +及 Merge Queue CI,直至读回最终 `main` merge SHA。动态 PR/run/SHA 证据不反写 +已经归档的稳定任务记录。 + +## Focused validation + +```bash +mise run format:check +mise run test:unit -- tests/renderer/platform/firstUseGuidePort.test.ts tests/renderer/pages/agents/Page.test.tsx +cargo test --manifest-path src-tauri/Cargo.toml save_settings_preserves_native_first_use_fields_in_both_directions -- --nocapture +python ./.trellis/scripts/task.py validate .trellis/tasks/09-15-review-todays-specs-and-merge +``` + +具体 Rust 测试过滤器以修改后的测试名为准。完整准入使用: + +```bash +TRELLIS_CONTEXT_ID=fyagent-review-todays-specs \ + mise run check:prearchive \ + --exclude-active-task .trellis/tasks/09-15-review-todays-specs-and-merge +``` + +归档后执行: + +```bash +mise run check:contracts +``` + +## Review gates + +- 普通设置保存不能改变任一首次引导原生字段。 +- 新目录身份不能在缺少用途说明时通过类型检查。 +- SPEC 只保留稳定契约,不记录本次 run ID、提交 SHA 或重复历史测试数字。 +- 浏览器夹具、macOS 宿主测试和 GitHub CI 各自只支持对应证据层级。 +- 推送后的 PR head 必须等于本地已复核 head;任何新提交都使旧 auto-merge handoff + 失效并要求更新 exact-head guard。 + +## Validation evidence + +- 聚焦 Renderer/端口回归:2 个文件、47 项通过。 +- 聚焦 Rust 回归:普通设置双向防覆盖 1 项、首次状态模块 5 项通过。 +- `check:prearchive` 退出码为 0:188 个 Vitest 文件中 1680 项通过、1 项跳过; + Rust 核心 3246 项通过、5 项按声明忽略,后续集成/辅助组件套件均无失败;桌面 + mock、视觉基线预检、格式、类型、Lint、Clippy、release、平台和任务合同均通过。 +- `test:browser` 退出码为 0:生产 Renderer 构建和 route chunk 校验通过,首次引导 + 保持独立的 3.42 kB JS / 1.64 kB CSS chunk;生产启动 3 项、完整 Chromium/WebKit + 矩阵 616 项全部通过。 +- 浏览器测试使用受控 Tauri 夹具,只证明 Renderer 行为和生产分包;本次没有声称 + 完成 macOS/Windows 物理全新安装、安装器、签名或原生凭据存储 HIL 验收。 diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/prd.md b/.trellis/tasks/09-15-review-todays-specs-and-merge/prd.md new file mode 100644 index 000000000..322e88cbf --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/prd.md @@ -0,0 +1,60 @@ +# 复核今日变更的 SPEC 并完成合并 + +## Goal + +复核 `dev/laiyongjie` 在 2026-09-15 新增的首次使用推荐引导及 Grok Build +纠错提交,使实现、测试与 Trellis SPEC 的可执行契约一致;完成本地质量门禁、 +任务归档、PR、PR/Merge Queue CI 和最终 `main` 合并读回。 + +## Background + +- 当前分支相对 `origin/dev/laiyongjie` 领先六个提交,产品范围是软件目录正向简介、 + 仅新安装可见的可跳过推荐引导,以及补齐 Grok Build 编程推荐。 +- 原任务已经各自归档。本任务只评审它们形成的当前行为,不改写历史验收记录。 +- 当前实现跨越设备本地设置、Rust 命令与 ACL、Renderer 端口/查询、目录页面、 + 浏览器回归和生产分包,因此必须按跨层 code-spec 深度复核。 +- 评审发现普通 `save_settings` 仍可能通过旧兼容字段结束引导;推荐理由表也允许 + 未来目录身份回退到通用目录简介,二者都削弱了既有窄边界。 + +## Requirements + +- R1:逐项核对今天六个提交的实现、测试、任务记录和相关 SPEC,确认每个稳定 + 行为只有一个语义 owner;必要时补充、改写或拆分,避免把一次性执行证据写入 + SPEC。 +- R2:首次安装资格必须由原生设备状态 owner 判定。只有数据库、旧配置和 + `settings.json` 均确认不存在时才能初始化 `pending`;存在、损坏、不可读或 + 检查失败均不得推断为新安装。 +- R3:`firstUseGuideState` 与旧兼容确认字段均为原生维护字段。普通整份设置保存 + 必须原样保留最新值,不能打开或结束引导;仅无参数窄命令可以持久化完成状态。 +- R4:办公、编程、混合推荐继续与已解析目录取交集并保留目录顺序。当前七个目录 + 身份必须拥有显式用途说明;不得用通用目录简介静默兜底新身份。 +- R5:保留现有产品边界:推荐不安装、不登录、不改模型、不上传用途;有效目标 + 链接优先;目录读取失败不写确认;隐藏/卸载期间不抢焦点或启动扫描。 +- R6:补齐与修复相匹配的回归,执行 Trellis 上下文校验、聚焦检查和完整归档前 + 门禁;不得用浏览器夹具声称完成真实新安装或跨平台安装器验收。 +- R7:完成任务归档和归档后契约检查,仅在最终差异、工作树和 base drift 均已 + 复核后推送精确 PR head;通过 PR CI 与 Merge Queue `CI / Required` 后合并, + 读回最终 `main` merge SHA。不得使用 `--admin` 或直接推送 `main`。 + +## Acceptance Criteria + +- AC1 / R1:前后端首次引导 owner SPEC 均包含 Scope、Signatures、Contracts、 + Validation/Error Matrix、Good/Base/Bad、Tests Required、Wrong vs Correct;索引和 + 相邻目录/文案契约只保留自己的边界,没有重复第二套状态机。 +- AC2 / R2–R3:回归证明现有 `pending` + 普通设置入参中的旧确认值不能结束引导, + 已完成状态也不能被旧快照重新打开;专用命令仍是唯一完成路径。 +- AC3 / R4:推荐理由映射对封闭 `AgentCatalogId` 穷尽,当前目录单用途并集仍覆盖 + 七个身份,Grok Build 顺序、名称和说明回归继续通过。 +- AC4 / R5:现有首次读取失败、显式 target、保存失败/防重入、隐藏完成、最小 + 视口、懒加载和正向目录文案测试保持通过。 +- AC5 / R6:聚焦测试和完整 `check:prearchive` 退出码为 0,任务 context 校验通过; + 真实未执行项与证据边界记录在任务中。 +- AC6 / R7:任务成功归档,归档后 `check:contracts` 通过;PR 精确 head 的检查通过, + Merge Queue 完成 merge-group 检查并在 `main` 形成可读回的 merge commit。 + +## Out of Scope + +- 不新增软件、用途选项、推荐服务、遥测、安装/登录/模型能力或数据库 schema。 +- 不改变三组推荐名单、目录顺序、主路由、设置文件位置或现有包体积上限。 +- 不重写两个已归档任务的历史材料,不清理与本次契约无关的旧设置字段或前端类型。 +- 不把本机自动化升级为 macOS/Windows 物理全新安装、签名或发行验收结论。 diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/task.json b/.trellis/tasks/09-15-review-todays-specs-and-merge/task.json new file mode 100644 index 000000000..1438eb112 --- /dev/null +++ b/.trellis/tasks/09-15-review-todays-specs-and-merge/task.json @@ -0,0 +1,26 @@ +{ + "id": "review-todays-specs-and-merge", + "name": "review-todays-specs-and-merge", + "title": "复核今日变更的 SPEC 并完成合并", + "description": "复核 2026-09-15 首次使用引导相关提交,按 Trellis 更新或拆分 SPEC,验证后提交 PR、修复 CI 并合并到 main。", + "status": "in_progress", + "dev_type": null, + "scope": "first-use-guide-spec-review", + "package": null, + "priority": "P2", + "creator": "pythonrust", + "assignee": "pythonrust", + "createdAt": "2026-09-15", + "completedAt": null, + "branch": "dev/laiyongjie", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 1e5e47a4a..c2836c5bc 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -54,11 +54,11 @@ fn merge_settings_for_save( incoming.current_provider_openclaw = existing.current_provider_openclaw.clone(); incoming.current_provider_hermes = existing.current_provider_hermes.clone(); incoming.appearance_theme = existing.appearance_theme.clone(); - // A stale settings snapshot must not reopen a completed first-use guide. + // Both first-use fields are native-owned. A full settings snapshot must + // neither reopen a completed guide nor dismiss a pending one through the + // legacy acknowledgement field; only dismiss_first_use_guide may advance it. incoming.first_use_guide_state = existing.first_use_guide_state; - if existing.first_run_notice_confirmed == Some(true) { - incoming.first_run_notice_confirmed = Some(true); - } + incoming.first_run_notice_confirmed = existing.first_run_notice_confirmed; incoming } @@ -660,20 +660,43 @@ mod tests { } #[test] - fn save_settings_should_preserve_existing_first_use_guide_state() { - let existing = crate::settings::AppSettings { - first_use_guide_state: Some(crate::settings::FirstUseGuideState::Dismissed), - first_run_notice_confirmed: Some(true), - ..crate::settings::AppSettings::default() - }; - let incoming = crate::settings::AppSettings { - first_use_guide_state: Some(crate::settings::FirstUseGuideState::Pending), - first_run_notice_confirmed: Some(false), - ..crate::settings::AppSettings::default() - }; - let merged = merge_settings_for_save(incoming, &existing); - assert_eq!(merged.first_use_guide_state, existing.first_use_guide_state); - assert_eq!(merged.first_run_notice_confirmed, Some(true)); + fn save_settings_preserves_native_first_use_fields_in_both_directions() { + for (existing_state, existing_legacy, incoming_state, incoming_legacy) in [ + ( + Some(crate::settings::FirstUseGuideState::Dismissed), + Some(true), + Some(crate::settings::FirstUseGuideState::Pending), + Some(false), + ), + ( + Some(crate::settings::FirstUseGuideState::Pending), + None, + Some(crate::settings::FirstUseGuideState::Dismissed), + Some(true), + ), + ( + None, + None, + Some(crate::settings::FirstUseGuideState::Dismissed), + Some(true), + ), + ] { + let existing = crate::settings::AppSettings { + first_use_guide_state: existing_state, + first_run_notice_confirmed: existing_legacy, + ..crate::settings::AppSettings::default() + }; + let incoming = crate::settings::AppSettings { + first_use_guide_state: incoming_state, + first_run_notice_confirmed: incoming_legacy, + ..crate::settings::AppSettings::default() + }; + + let merged = merge_settings_for_save(incoming, &existing); + + assert_eq!(merged.first_use_guide_state, existing_state); + assert_eq!(merged.first_run_notice_confirmed, existing_legacy); + } } #[test] diff --git a/src-tauri/src/settings/first_use_guide.rs b/src-tauri/src/settings/first_use_guide.rs index ebfd7cb16..0bbc315cc 100644 --- a/src-tauri/src/settings/first_use_guide.rs +++ b/src-tauri/src/settings/first_use_guide.rs @@ -21,6 +21,15 @@ fn initial_state(settings: &AppSettings, fresh_install: bool) -> FirstUseGuideSt }) } +fn should_initialize_pending( + settings: &AppSettings, + new_data_dir: bool, + no_settings_file: bool, +) -> bool { + settings.first_use_guide_state.is_none() + && initial_state(settings, new_data_dir && no_settings_file) == FirstUseGuideState::Pending +} + /// Persist eligibility before database creation/seeding so an unfinished guide /// survives a restart. Missing markers on existing or unreadable data are not /// evidence of a new install; those users keep the ordinary directory. @@ -28,9 +37,7 @@ pub(crate) fn initialize_first_use_guide(new_data_dir: bool) -> Result<(), AppEr let settings = get_settings(); let no_settings_file = AppSettings::settings_path().is_some_and(|path| matches!(path.try_exists(), Ok(false))); - if settings.first_use_guide_state.is_none() - && initial_state(&settings, new_data_dir && no_settings_file) == FirstUseGuideState::Pending - { + if should_initialize_pending(&settings, new_data_dir, no_settings_file) { mutate_settings(|current| { current.first_use_guide_state = Some(FirstUseGuideState::Pending); })?; @@ -64,6 +71,34 @@ mod tests { ); } + #[test] + fn initialization_requires_every_absence_check_and_no_prior_marker() { + let settings = AppSettings::default(); + for (new_data_dir, no_settings_file, expected) in [ + (true, true, true), + (true, false, false), + (false, true, false), + (false, false, false), + ] { + assert_eq!( + should_initialize_pending(&settings, new_data_dir, no_settings_file), + expected + ); + } + + let pending = AppSettings { + first_use_guide_state: Some(FirstUseGuideState::Pending), + ..AppSettings::default() + }; + assert!(!should_initialize_pending(&pending, true, true)); + + let legacy_confirmed = AppSettings { + first_run_notice_confirmed: Some(true), + ..AppSettings::default() + }; + assert!(!should_initialize_pending(&legacy_confirmed, true, true)); + } + #[test] fn pending_survives_database_creation_and_settings_roundtrip() { let settings = AppSettings { diff --git a/src/pages/agents/firstUseRecommendations.ts b/src/pages/agents/firstUseRecommendations.ts index ff8a744f7..ce2405de9 100644 --- a/src/pages/agents/firstUseRecommendations.ts +++ b/src/pages/agents/firstUseRecommendations.ts @@ -21,7 +21,7 @@ const recommendedIds: Record = { both: ["workbuddy", "codex"], }; -const reasons: Partial> = { +const reasons: Record = { qoderwork: "整理文件、处理数据与生成文档", "trae-work": "文档、演示稿与资料调研", workbuddy: "处理日常办公任务", @@ -39,6 +39,6 @@ export function firstUseRecommendations( .filter((entry) => recommendedIds[purpose].includes(entry.id)) .map((entry) => ({ entry, - reason: reasons[entry.id] ?? entry.description, + reason: reasons[entry.id], })); } From 1788ef1577ac48268cc5be80ce04a62cc17ed47e Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:06:44 +0800 Subject: [PATCH 8/9] chore(task): archive 09-15-review-todays-specs-and-merge --- .../09-15-review-todays-specs-and-merge/check.jsonl | 0 .../2026-09}/09-15-review-todays-specs-and-merge/design.md | 0 .../09-15-review-todays-specs-and-merge/implement.jsonl | 0 .../09-15-review-todays-specs-and-merge/implement.md | 0 .../2026-09}/09-15-review-todays-specs-and-merge/prd.md | 0 .../2026-09}/09-15-review-todays-specs-and-merge/task.json | 6 +++--- 6 files changed, 3 insertions(+), 3 deletions(-) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/design.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/implement.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/prd.md (100%) rename .trellis/tasks/{ => archive/2026-09}/09-15-review-todays-specs-and-merge/task.json (92%) diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/check.jsonl similarity index 100% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/check.jsonl rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/check.jsonl diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/design.md b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/design.md similarity index 100% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/design.md rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/design.md diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/implement.jsonl similarity index 100% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/implement.jsonl rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/implement.jsonl diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/implement.md b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/implement.md similarity index 100% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/implement.md rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/implement.md diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/prd.md b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/prd.md similarity index 100% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/prd.md rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/prd.md diff --git a/.trellis/tasks/09-15-review-todays-specs-and-merge/task.json b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/task.json similarity index 92% rename from .trellis/tasks/09-15-review-todays-specs-and-merge/task.json rename to .trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/task.json index 1438eb112..d8d3e9bdb 100644 --- a/.trellis/tasks/09-15-review-todays-specs-and-merge/task.json +++ b/.trellis/tasks/archive/2026-09/09-15-review-todays-specs-and-merge/task.json @@ -3,7 +3,7 @@ "name": "review-todays-specs-and-merge", "title": "复核今日变更的 SPEC 并完成合并", "description": "复核 2026-09-15 首次使用引导相关提交,按 Trellis 更新或拆分 SPEC,验证后提交 PR、修复 CI 并合并到 main。", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": "first-use-guide-spec-review", "package": null, @@ -11,7 +11,7 @@ "creator": "pythonrust", "assignee": "pythonrust", "createdAt": "2026-09-15", - "completedAt": null, + "completedAt": "2026-09-15", "branch": "dev/laiyongjie", "base_branch": "main", "worktree_path": null, @@ -23,4 +23,4 @@ "relatedFiles": [], "notes": "", "meta": {} -} +} \ No newline at end of file From 17fa4b5edd1e0266c86a8e3b1edbab7b356647d3 Mon Sep 17 00:00:00 2001 From: Kafu <153478754+python-rust@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:07:02 +0800 Subject: [PATCH 9/9] chore: record journal --- .trellis/workspace/pythonrust/index.md | 5 +-- .trellis/workspace/pythonrust/journal-2.md | 37 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/pythonrust/index.md b/.trellis/workspace/pythonrust/index.md index 80b001905..e6828a321 100644 --- a/.trellis/workspace/pythonrust/index.md +++ b/.trellis/workspace/pythonrust/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-2.md` -- **Total Sessions**: 92 +- **Total Sessions**: 93 - **Last Active**: 2026-09-15 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-2.md` | ~905 | Active | +| `journal-2.md` | ~942 | Active | | `journal-1.md` | ~1987 | Archived | @@ -30,6 +30,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 93 | 2026-09-15 | 复核首次使用引导 SPEC 与状态所有权 | `ebcffdee5ed0684c6364f74922038c10e5f39a76` | `dev/laiyongjie` | | 92 | 2026-09-15 | 补齐 Grok Build 首次推荐并防遗漏 | `4eb7e346b88b9386710d2b21e5769d8b4d2d3ff7` | `dev/laiyongjie` | | 91 | 2026-09-15 | AI 软件正向文案与可跳过的首次推荐引导 | `ae1da25a125d23ca7823d3ebc4ac570fd662d020` | `dev/laiyongjie` | | 90 | 2026-09-14 | Stabilize merge-queue contrast sampling | `5be5540c` | `dev/laiyongjie` | diff --git a/.trellis/workspace/pythonrust/journal-2.md b/.trellis/workspace/pythonrust/journal-2.md index ac5274757..269a3d027 100644 --- a/.trellis/workspace/pythonrust/journal-2.md +++ b/.trellis/workspace/pythonrust/journal-2.md @@ -903,3 +903,40 @@ Diagnosed PR #188 merge-group WebKit failure as a two-phase raster sampling race ### Status [OK] **Completed** + + +## Session 93: 复核首次使用引导 SPEC 与状态所有权 + + +**Date**: 2026-09-15 +**Task**: 复核首次使用引导 SPEC 与状态所有权 +**Branch**: `dev/laiyongjie` + +### Summary + +复核 2026-09-15 首次使用推荐引导提交,修复普通设置保存可绕过窄完成命令的问题,并收敛前后端 SPEC、穷尽推荐说明与验证证据。 + +### Main Changes + +- 普通设置保存原样保留两个原生首次引导字段,专用命令成为唯一完成路径。 +- 推荐说明改为封闭 AgentCatalogId 的穷尽映射,移除通用目录简介兜底。 +- 更新前后端 first-use owner SPEC、目录边界与 Trellis 任务证据。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `ebcffdee5ed0684c6364f74922038c10e5f39a76` | fix(agents): enforce first-use guide ownership | + +### Testing + +- [OK] check:prearchive 通过;Vitest 1680 项通过、Rust 核心 3246 项通过。 +- [OK] test:browser 通过;生产启动 3 项与 Chromium/WebKit 616 项全部通过。 + +### Status + +[OK] **Completed** + +### Next Steps + +- 推送精确分支 head,创建 PR 并跟进 PR/Merge Queue CI 至 main 合并。