diff --git a/assets/brand/README.md b/assets/brand/README.md index 4130ba203d..ac5e25a3f6 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -108,12 +108,6 @@ platform selection, and icon containers with `node --test scripts/generate-brand-assets.test.mjs`. macOS export and native bundling checks also run `scripts/macos-icon-bundle.test.mjs`. -`source/release-letter-mascot.svg` preserves the authored character from -`openbitfun-letter.html`. Its named body, rigid head/rod, and eye parts are used -by the release letter's connected animation rig. The gradients belong to this -illustration, not to the application theme. Vite bundles this source directly; -it is independent of the application icon generator and needs no PNG export. - Verification emails use the silver application mark on its black rounded-square background, generated at `src/miniapp-market-web/public/assets/openbitfun-email-app-icon.png`. The market build diff --git a/assets/brand/source/release-letter-mascot.svg b/assets/brand/source/release-letter-mascot.svg deleted file mode 100644 index ca8d12901a..0000000000 --- a/assets/brand/source/release-letter-mascot.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index e27c81804c..75f418dbfc 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -357,7 +357,14 @@ routing, and device or session operations stay in the consuming application. `Checkbox appearance="native"` uses the visible browser checkbox, retaining its native focus, disabled, checked, indeterminate, and form semantics. The default -`custom` presentation remains unchanged. Both presentations share sm/md/lg +`custom` presentation uses `color.control.highlight.background` for checked and +indeterminate boxes and reuses `color.control.switch.thumb` for their marks. +This matches the Switch's filled highlight and indicator pairing: white marks +in ordinary light/dark themes, with a dark mark on the high-contrast dark theme's +light highlight. `color.control.highlight.content` belongs to content on a +highlighted surface and has a dark value in ordinary themes. Select and Combobox +instead use the highlight color for a standalone check on a neutral surface. +Forced colors use the system Highlight/HighlightText pair. Both presentations share sm/md/lg sizing and the same label and description slots; native colors belong to the browser, with optional consumer `accent-color` styling. diff --git a/design-system/packages/ui/src/components/Checkbox/Checkbox.meta.ts b/design-system/packages/ui/src/components/Checkbox/Checkbox.meta.ts index b35cf6cd02..62344c9223 100644 --- a/design-system/packages/ui/src/components/Checkbox/Checkbox.meta.ts +++ b/design-system/packages/ui/src/components/Checkbox/Checkbox.meta.ts @@ -16,5 +16,5 @@ export const checkboxMeta = { { name: "size", type: "sm | md | lg", defaultValue: "md" }, ], states: ["default", "hover", "focus-visible", "checked", "indeterminate", "invalid", "disabled"], - tokens: ["color.content.primary", "color.content.muted", "color.content.disabled", "color.field.background", "color.field.border", "color.accent.default", "color.focus.ring"], + tokens: ["color.content.primary", "color.content.muted", "color.content.disabled", "color.field.background", "color.field.border", "color.control.highlight.background", "color.control.switch.thumb", "color.focus.ring"], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css b/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css index a8b7079597..65d44dcf61 100644 --- a/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css +++ b/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css @@ -59,14 +59,14 @@ block-size: 100%; border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-field-border); border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-inverse); + color: var(--openbitfun-color-control-switch-thumb); background: var(--openbitfun-color-field-background); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .input:is(:checked, :indeterminate) + .box { - border-color: var(--openbitfun-color-accent-default); - background: var(--openbitfun-color-accent-default); + border-color: var(--openbitfun-color-control-highlight-background); + background: var(--openbitfun-color-control-highlight-background); } .input:focus-visible + .box { @@ -99,6 +99,6 @@ @media (prefers-reduced-motion: reduce) { .box { transition: none; } } @media (forced-colors: active) { .box { border-color: ButtonText; } - .input:is(:checked, :indeterminate) + .box { border-color: Highlight; background: Highlight; } + .input:is(:checked, :indeterminate) + .box { border-color: Highlight; color: HighlightText; background: Highlight; } } } diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index dbf962b8b6..9f3287af17 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**325 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**324 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **325** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **324** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **670** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **664** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **670** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **664** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **214**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **213**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 325 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 324 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **214 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **213 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 325 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 324 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 6eacd2da54..3c5126dbfc 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", - "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", + "digest": "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b", + "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 325, + "documentedItems": 324, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 214, + "interactive": 213, "unsupported": 0 } }, @@ -10836,8 +10836,7 @@ "launch-at-login", "prevent-sleep", "close-behavior", - "notifications", - "announcements" + "notifications" ], "kind": "query", "risk": "read", @@ -11155,50 +11154,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:option:startup-tips", - "capabilityId": "setting.application.general", - "itemIds": [ - "notifications" - ], - "kind": "option", - "risk": "write", - "executionHost": "productHost", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": true - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": true - } - }, - "inputSchema": { - "type": "boolean" - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "valueSource": { - "kind": "static" - }, - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.general:open:manual-update", "capabilityId": "setting.application.general", @@ -11245,52 +11200,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:open:announcements", - "capabilityId": "setting.application.general", - "itemIds": [ - "announcements" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.appearance:query", "capabilityId": "setting.application.appearance", @@ -25079,12 +24988,12 @@ "highlightsZh": [ "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知" + "任务完成与权限请求通知" ], "highlightsEn": [ "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications" + "Completion and permission notifications" ], "items": [ { @@ -25156,8 +25065,8 @@ }, { "id": "notifications", - "titleZh": "分别控制任务完成、权限请求和启动提示通知", - "titleEn": "Control completion, permission-request, and startup-tip notifications independently", + "titleZh": "分别控制任务完成和权限请求通知", + "titleEn": "Control completion and permission-request notifications independently", "control": { "kind": "direct", "operations": [], @@ -25167,23 +25076,9 @@ }, { "id": "permission-notification" - }, - { - "id": "startup-tips" } ] } - }, - { - "id": "announcements", - "titleZh": "查看、标记已读、忽略或永久隐藏产品公告与提示", - "titleEn": "Review, mark seen, dismiss, or permanently hide product announcements and tips", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看、标记已读、忽略或永久隐藏产品公告与提示”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Review, mark seen, dismiss, or permanently hide product announcements and tips” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } } ], "stepsZh": [ @@ -25276,16 +25171,6 @@ "valueSchema": { "type": "boolean" } - }, - { - "id": "startup-tips", - "titleZh": "启动提示", - "titleEn": "Startup tips", - "descriptionZh": "启动后显示功能提示。", - "descriptionEn": "Show product tips after startup.", - "valueSchema": { - "type": "boolean" - } } ], "searchTerms": [ @@ -25307,10 +25192,10 @@ "prevent sleep", "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知", + "任务完成与权限请求通知", "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications", + "Completion and permission notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", @@ -25321,10 +25206,8 @@ "Choose whether OpenBitFun prevents the computer from sleeping while it runs", "选择关闭主窗口时退出、最小化到托盘或每次询问", "Choose whether closing the main window quits, minimizes to tray, or asks each time", - "分别控制任务完成、权限请求和启动提示通知", - "Control completion, permission-request, and startup-tip notifications independently", - "查看、标记已读、忽略或永久隐藏产品公告与提示", - "Review, mark seen, dismiss, or permanently hide product announcements and tips", + "分别控制任务完成和权限请求通知", + "Control completion and permission-request notifications independently", "关闭自动更新", "以后关闭窗口时最小化到托盘", "打开常规设置", diff --git a/docs/interactive-capabilities/capabilities/setting.application.general.md b/docs/interactive-capabilities/capabilities/setting.application.general.md index 4e20f13e4d..dbc50bfe6a 100644 --- a/docs/interactive-capabilities/capabilities/setting.application.general.md +++ b/docs/interactive-capabilities/capabilities/setting.application.general.md @@ -27,10 +27,8 @@ Manage app-level preferences for startup, updates, close behavior, notifications - Choose whether OpenBitFun prevents the computer from sleeping while it runs - **智能体可直接控制 / Direct Agent control** · 选择关闭主窗口时退出、最小化到托盘或每次询问 - Choose whether closing the main window quits, minimizes to tray, or asks each time -- **智能体可直接控制 / Direct Agent control** · 分别控制任务完成、权限请求和启动提示通知 - - Control completion, permission-request, and startup-tip notifications independently -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 查看、标记已读、忽略或永久隐藏产品公告与提示 - - Review, mark seen, dismiss, or permanently hide product announcements and tips +- **智能体可直接控制 / Direct Agent control** · 分别控制任务完成和权限请求通知 + - Control completion and permission-request notifications independently ## 怎么用 / How to use it @@ -59,7 +57,6 @@ Manage app-level preferences for startup, updates, close behavior, notifications | 关闭按钮行为 / Close button behavior | `ask` / `minimize_to_tray` / `quit` | 选择退出应用或最小化到托盘。 | Choose whether closing quits the app or minimizes it to the tray. | | 任务完成通知 / Completion notifications | `boolean` | 会话任务完成时发送系统通知。 | Send an OS notification when session work completes. | | 权限请求通知 / Permission request notifications | `boolean` | 智能体等待权限确认时发送系统通知。 | Send an OS notification when an agent waits for permission. | -| 启动提示 / Startup tips | `boolean` | 启动后显示功能提示。 | Show product tips after startup. | ## 可以直接对智能体说 / Try saying diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index fd51f91eae..2753cd560c 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", - "count": 214, + "catalogDigest": "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b", + "count": 213, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 185, + "unstructuredInteraction": 184, "visualSelection": 20 }, "entries": [ @@ -2518,27 +2518,6 @@ "command:get_app_version" ] }, - { - "capabilityId": "setting.application.general", - "itemId": "announcements", - "titleZh": "查看、标记已读、忽略或永久隐藏产品公告与提示", - "titleEn": "Review, mark seen, dismiss, or permanently hide product announcements and tips", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看、标记已读、忽略或永久隐藏产品公告与提示”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Review, mark seen, dismiss, or permanently hide product announcements and tips” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - }, - "evidence": [ - "command:get_pending_announcements", - "command:mark_announcement_seen", - "command:dismiss_announcement", - "command:never_show_announcement", - "command:get_announcement_tips", - "command:trigger_announcement" - ] - }, { "capabilityId": "setting.application.appearance", "itemId": "ui-font-size", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index f51bbba056..130e062c49 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", - "commandCount": 670, + "catalogDigest": "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b", + "commandCount": 664, "coverage": { - "commandCount": 670, - "documentedCommandCount": 622, + "commandCount": 664, + "documentedCommandCount": 616, "implementationCommandCount": 48, "implementationDigest": "f6d38a24a70708988cb47ada81d07eccf0668684e8734c9ee5155b9ffa7e3db8" }, @@ -1950,22 +1950,6 @@ "signature": "fn delete_subagent( state: State<'_, AppState>, request: DeleteSubagentRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "dismiss_announcement", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::dismiss_announcement", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn dismiss_announcement( state: State<'_, AppState>, request: AnnouncementIdRequest, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "dispatch_answer", "moduleId": "dispatch", @@ -2746,22 +2730,6 @@ "signature": "fn get_all_tools_info() -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "get_announcement_tips", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::get_announcement_tips", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn get_announcement_tips( state: State<'_, AppState>, ) -> Result, String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "get_app_state", "moduleId": "commands", @@ -3597,22 +3565,6 @@ "signature": "fn get_operation_summary( runtime: State<'_, DesktopRuntimeContext>, mut request: GetOperationSummaryRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, - { - "id": "get_pending_announcements", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::get_pending_announcements", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn get_pending_announcements( state: State<'_, AppState>, ) -> Result, String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "get_pending_update", "moduleId": "update", @@ -5506,22 +5458,6 @@ "signature": "fn manage_dialog_queue( runtime: State<'_, DesktopRuntimeContext>, request: openbitfun_runtime_ports::DialogQueueRequest, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, - { - "id": "mark_announcement_seen", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::mark_announcement_seen", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn mark_announcement_seen( state: State<'_, AppState>, request: AnnouncementIdRequest, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "mark_openbitfun_control_surface_ready", "moduleId": "openbitfun_control_host", @@ -6312,22 +6248,6 @@ "signature": "fn mutate_external_hook_import_command( request: MutateExternalHookImportRequest, ) -> ExternalSourceOperationResult", "remoteWorkspacePolicy": "RemoteUnsupported" }, - { - "id": "never_show_announcement", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::never_show_announcement", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn never_show_announcement( state: State<'_, AppState>, request: AnnouncementIdRequest, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "notify_cron_host_ready", "moduleId": "cron", @@ -10217,22 +10137,6 @@ "signature": "fn touch_session_activity( request: TouchSessionActivityRequest, runtime: State<'_, DesktopRuntimeContext>, startup_trace: State<'_, DesktopStartupTrace>, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "trigger_announcement", - "moduleId": "announcement", - "capabilityId": "setting.application.general", - "capabilityIds": [ - "setting.application.general" - ], - "documentedItemIds": [ - "setting.application.general:announcements" - ], - "visibility": "documented", - "rustPath": "api::announcement_api::trigger_announcement", - "sourceFile": "src/apps/desktop/src/api/announcement_api.rs", - "signature": "fn trigger_announcement( state: State<'_, AppState>, request: AnnouncementIdRequest, ) -> Result, String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "unarchive_session", "moduleId": "session", diff --git a/package.json b/package.json index 5aa801ed84..35a2615ff8 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,6 @@ "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", "desktop:preview:debug": "node scripts/dev.cjs desktop-preview", - "desktop:preview:release-letter": "cross-env VITE_OPENBITFUN_ANNOUNCEMENT_PREVIEW_ID=release_letter_1_0_0 node scripts/dev.cjs desktop-preview", "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", "target:gc": "node scripts/cargo-target-gc.mjs", "product:check": "node scripts/product-customization/cli.mjs check", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index ad46104191..78087242ce 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1801,7 +1801,6 @@ test('SDK Host closure rejects unreviewed capability owners below Core', () => { ['openbitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh'], ['openbitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh-concrete'], ['openbitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'function-agents'], - ['openbitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'announcement'], ['openbitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'product-full'], ['openbitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'function-agents'], ['openbitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'product-full'], @@ -2723,8 +2722,8 @@ test('CLI dependency closure includes build dependencies and excluded capabiliti const core = { ...packageAt('openbitfun-core', 'src/crates/assembly/core/Cargo.toml'), features: { - 'cli-everything': ['announcement'], - announcement: [], + 'cli-everything': ['product-full'], + 'product-full': [], }, }; const bridge = packageAt('bridge', 'src/crates/assembly/bridge/Cargo.toml', [ @@ -2745,7 +2744,7 @@ test('CLI dependency closure includes build dependencies and excluded capabiliti ); assert.equal(violations.length, 1); - assert.match(violations[0].message, /openbitfun-core\/announcement/); + assert.match(violations[0].message, /openbitfun-core\/product-full/); }); test('CLI dependency closure excludes the Core dispatch store', () => { @@ -2864,7 +2863,7 @@ test('services integrations Reqwest policy uses Cargo-decoded feature references const pkg = servicesIntegrationsPackage(` [features] reqwest = ["dep:reqwest"] -announcement = ["reqwest", "reqwest/rustls-no-provider"] +browser-control = ["reqwest", "reqwest/rustls-no-provider"] file-watch = ["reqwest?/__native-tls"] mcp = ["reqwest", "reqwest/rustls-no-provider", "reqwest/json"] models-dev = ["reqwest", "reqwest/rustls-no-provider", "reqwest/system-proxy"] @@ -2874,7 +2873,7 @@ speech = ["reqwest", "reqwest/rustls-no-provider", "reqwest/http3"] const messages = findServicesIntegrationsReqwestFeatureViolations(pkg) .map((violation) => violation.message) .join('\n'); - assert.match(messages, /announcement.*missing Reqwest feature reference reqwest\/json/); + assert.match(messages, /browser-control.*missing Reqwest feature reference reqwest\/json/); assert.match(messages, /file-watch.*outside its reviewed owner features/); assert.match(messages, /mcp.*missing Reqwest feature reference reqwest\/stream/); assert.doesNotMatch(messages, /models-dev.*system-proxy/); @@ -3820,10 +3819,7 @@ test('desktop preview rebuild inputs use the current crate layout', async () => devScript, /'src', 'crates', '(?:core|transport|events|ai-adapters|webdriver|api-layer|assembly|adapters|contracts|execution|interfaces|services)'/, ); - assert.match( - devScript, - /service\$\{path\.sep\}announcement\$\{path\.sep\}content/, - ); + assert.match(devScript, /prompts\$\{path\.sep\}/); }); test('split core boundary check keeps self-test execution behavior', () => { @@ -4382,11 +4378,11 @@ test('closed feature profiles reject product-full hidden behind a child feature' './core-boundaries/manifest-feature-helpers.mjs' ); const features = new Map([ - ['service-integrations', { refs: ['announcement'], line: 1 }], + ['service-integrations', { refs: ['file-watch'], line: 1 }], [ - 'announcement', + 'file-watch', { - refs: ['openbitfun-services-integrations/announcement', 'product-full'], + refs: ['openbitfun-services-integrations/file-watch', 'product-full'], line: 2, }, ], @@ -4397,12 +4393,12 @@ test('closed feature profiles reject product-full hidden behind a child feature' unexpectedReachableLocalFeatures( features, 'service-integrations', - new Set(['announcement']), + new Set(['file-watch']), ), [ { featureName: 'product-full', - path: ['service-integrations', 'announcement', 'product-full'], + path: ['service-integrations', 'file-watch', 'product-full'], }, ], ); diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 26178ca5de..e094f45ba3 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -144,7 +144,6 @@ function isProcMacroPackage(pkg) { const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['account-identity', ['rt', 'sync']], - ['announcement', ['fs', 'sync']], // Large catalog validation/hash work uses spawn_blocking, outside async IO workers. ['models-dev', ['fs', 'rt', 'sync', 'time']], ['browser-control', ['time']], @@ -1064,7 +1063,6 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const ownerFeatures = new Set(servicesReqwestOwnerFeatures); const ownerFeatureReferences = new Map([ ['account-identity', ['reqwest/json']], - ['announcement', ['reqwest/json']], ['browser-control', ['reqwest/json']], ['mcp', ['reqwest/json', 'reqwest/stream']], ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], @@ -1560,7 +1558,6 @@ export function findProductEntrypointCoreFeatureViolations( const reviewedForbiddenDependencyOwnerFeatures = new Map([ ['openbitfun-sdk-host-app', new Map([ ['openbitfun-services-integrations', [ - 'announcement', 'function-agents', 'product-full', 'remote-connect', @@ -1779,7 +1776,6 @@ export function findProductEntrypointCoreFeatureViolations( if (corePackage) { const forbiddenCoreFeatures = [ 'product-full', - 'announcement', 'dispatch-store', ]; const reportedUnexpectedFeatures = new Set(); diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 307fe8fc84..6702a6e30f 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -51,7 +51,6 @@ export const servicesCoreIntegrationTestTargets = [ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'script_tool_runtime', path: 'tests/script_tool_runtime.rs' }, - { name: 'announcement_contracts', path: 'tests/announcement_contracts.rs' }, { name: 'file_watch_contracts', path: 'tests/file_watch_contracts.rs' }, { name: 'function_agent_contracts', path: 'tests/function_agent_contracts.rs' }, { name: 'git_contracts', path: 'tests/git_contracts.rs' }, diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 55500819a5..cebf08d27d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -2,7 +2,6 @@ export const servicesReqwestOwnerFeatures = [ 'account-identity', - 'announcement', 'browser-control', 'mcp', 'miniapp-market', @@ -194,7 +193,6 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'openbitfun-services-integrations', ownerFeatures: [ - 'announcement', 'canvas-runtime', 'browser-control', 'deep-research', @@ -291,7 +289,6 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-services-core', ownerFeatures: [ 'account-identity', - 'announcement', 'browser-control', 'git', 'hook-import', @@ -736,7 +733,6 @@ export const coreProductFullFeatureAssemblyRule = { 'tools-pages', 'web-tools', 'workspace-search', - 'announcement', 'canvas-runtime', 'dispatch-store', 'file-watch', @@ -1701,13 +1697,6 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'openbitfun-core remote-workspace must add only the remote workspace service surface', }, - { - manifestPath: 'src/crates/assembly/core/Cargo.toml', - featureName: 'announcement', - requiredFeatureRefs: ['openbitfun-services-integrations/announcement'], - exact: true, - reason: 'openbitfun-core announcement must select only the announcement owner capability', - }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'file-watch', @@ -1733,7 +1722,7 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'service-integrations', - requiredFeatureRefs: ['announcement', 'file-watch', 'git', 'review-platform'], + requiredFeatureRefs: ['file-watch', 'git', 'review-platform'], exact: true, reason: 'openbitfun-core service-integrations is a compatibility facade group, not a product capability umbrella', @@ -1786,7 +1775,6 @@ export const ownerCrateFeatureAssemblyRules = [ 'script-tool-runtime', ], requiredProductFullFeatures: [ - 'announcement', 'browser-control', 'canvas-runtime', 'deep-research', diff --git a/scripts/core-boundaries/rules/source/facade-rules.mjs b/scripts/core-boundaries/rules/source/facade-rules.mjs index b8599c9950..440d3c9281 100644 --- a/scripts/core-boundaries/rules/source/facade-rules.mjs +++ b/scripts/core-boundaries/rules/source/facade-rules.mjs @@ -121,9 +121,4 @@ export const facadeOnlyFiles = [ importPrefix: 'openbitfun_services_integrations::mcp', reason: 'core MCP prompt adapter facade must only re-export the integrations owner crate', }, - { - path: 'src/crates/assembly/core/src/service/announcement/types.rs', - importPrefix: 'openbitfun_services_integrations::announcement', - reason: 'core announcement types facade must only re-export the integrations owner crate', - }, ]; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index b0fae00f9e..ef4e638b71 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -3941,23 +3941,6 @@ export const forbiddenContentRules = [ }, ], }, - { - path: 'src/crates/assembly/core/src/service/announcement/state_store.rs', - patterns: [ - { - regex: /\btokio::fs\b/, - message: 'core announcement state store facade must not own filesystem persistence; use the integrations state store', - }, - { - regex: /\bserde_json::to_string_pretty\b/, - message: 'core announcement state store facade must not own state serialization; use the integrations state store', - }, - { - regex: /\bserde_json::from_str\b/, - message: 'core announcement state store facade must not own state deserialization; use the integrations state store', - }, - ], - }, { path: 'src/apps/server/src/bootstrap.rs', reason: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 183fd6ff41..a48a899d65 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3862,10 +3862,6 @@ export const requiredContentRules = [ reason: 'service integration and agent-runtime surfaces must not compile in no-default core builds', patterns: [ - { - regex: /#\[cfg\(feature = "announcement"\)\]\s*pub mod announcement\b/s, - message: 'announcement facade must stay behind its exact feature', - }, { regex: /#\[cfg\(feature = "file-watch"\)\]\s*pub use openbitfun_services_integrations::file_watch\b/s, message: 'file-watch facade must stay behind its exact feature', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 8dcd7caba0..e3a2a6ea51 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -372,7 +372,6 @@ export function runManifestParserSelfTest({ 'agent-runtime', 'diagnostics', 'diff', - 'announcement', 'canvas-runtime', 'dispatch-store', 'file-watch', @@ -563,11 +562,10 @@ export function runManifestParserSelfTest({ 'canvas-runtime', ['dep:openbitfun-product-domains', 'openbitfun-services-integrations/canvas-runtime'], ], - [coreManifest, 'announcement', ['openbitfun-services-integrations/announcement']], [coreManifest, 'file-watch', ['openbitfun-services-integrations/file-watch']], [coreManifest, 'git', ['openbitfun-services-integrations/git']], [coreManifest, 'review-platform', ['openbitfun-services-integrations/review-platform']], - [coreManifest, 'service-integrations', ['announcement', 'file-watch', 'git', 'review-platform']], + [coreManifest, 'service-integrations', ['file-watch', 'git', 'review-platform']], [ coreManifest, 'ssh-remote', @@ -684,16 +682,14 @@ export function runManifestParserSelfTest({ 'default = ["product-full"]', 'product-full = [', ' "dep:tool-runtime",', - ' "announcement",', ' "file-watch",', ' "git",', ' "review-platform",', ']', - 'announcement = ["openbitfun-services-integrations/announcement"]', 'file-watch = ["openbitfun-services-integrations/file-watch"]', 'git = ["openbitfun-services-integrations/git"]', 'review-platform = ["openbitfun-services-integrations/review-platform"]', - 'service-integrations = ["announcement", "file-watch", "git", "review-platform"]', + 'service-integrations = ["file-watch", "git", "review-platform"]', 'workspace-runtime = ["dep:serde_yaml", "openbitfun-services-core/workspace-runtime"]', 'remote-workspace = [', ' "workspace-runtime",', @@ -4541,8 +4537,6 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/src/service/mod.rs', contracts: [ - 'feature = "announcement"', - 'pub mod announcement', 'feature = "file-watch"', 'file_watch', 'feature = "git"', @@ -5210,13 +5204,6 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis } } - const announcementStateStoreRule = forbiddenContentRules.find( - (rule) => rule.path === 'src/crates/assembly/core/src/service/announcement/state_store.rs', - ); - if (!announcementStateStoreRule) { - throw new Error('missing announcement state store boundary rule'); - } - const mcpProcessRule = forbiddenContentRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/service/mcp/server/process.rs', ); diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 6c18df23c3..e0d55309f3 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -387,10 +387,7 @@ function getNewestTrackedInput(entryPath) { return null; } - const isEmbeddedMarkdown = entryPath.includes(`${path.sep}prompts${path.sep}`) - || entryPath.includes( - `${path.sep}service${path.sep}announcement${path.sep}content${path.sep}`, - ); + const isEmbeddedMarkdown = entryPath.includes(`${path.sep}prompts${path.sep}`); if (ext === '.md' && !isEmbeddedMarkdown) { return null; } diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index 9f0bc2b82a..5de4ee289c 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,13 +6,13 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 154, + "maxTotal": 153, "bySurface": { "core": 15, "installer": 0, "mobile-web": 0, "relay-static-homepage": 0, - "web-ui": 139 + "web-ui": 138 }, "bySharedKey": { "agents.claw": 0, @@ -31,7 +31,7 @@ "modes.review": 0, "product.name": 0, "statuses.cancelled": 24, - "statuses.done": 20, + "statuses.done": 19, "statuses.failed": 32, "statuses.loading": 5, "statuses.running": 6, diff --git a/src/apps/desktop/src/api/announcement_api.rs b/src/apps/desktop/src/api/announcement_api.rs deleted file mode 100644 index e2cf1fc961..0000000000 --- a/src/apps/desktop/src/api/announcement_api.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Announcement system Tauri commands. - -use crate::api::app_state::AppState; -use openbitfun_core::service::announcement::{AnnouncementCard, CardType}; -use serde::Deserialize; -use tauri::State; - -#[derive(Debug, Deserialize)] -pub struct AnnouncementIdRequest { - pub id: String, -} - -/// Return the ordered list of cards that should be displayed in this session. -/// -/// This triggers the scheduler (updates open count / version state) and returns -/// cards that pass all filter rules. Call once per application start. -/// Built-in tip cards are excluded when `app.notifications.enable_startup_tips` is false. -#[tauri::command] -pub async fn get_pending_announcements( - state: State<'_, AppState>, -) -> Result, String> { - let locale = state - .config_service - .get_config::(Some("app.general.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - - let mut cards = state - .announcement_scheduler - .run(&locale) - .await - .map_err(|e| format!("Failed to get pending announcements: {}", e))?; - - // Respect the user preference for startup tips. - let tips_enabled: bool = state - .config_service - .get_config::(Some("app.notifications.enable_startup_tips")) - .await - .unwrap_or(true); // default on if config is missing / unset - - if !tips_enabled { - cards.retain(|c| c.card_type != CardType::Tip); - } - - Ok(cards) -} - -/// Mark a card as seen (the user opened its modal or acknowledged it). -/// -/// Seen cards with `once_per_version = true` will not be shown again in the -/// current version cycle. -#[tauri::command] -pub async fn mark_announcement_seen( - state: State<'_, AppState>, - request: AnnouncementIdRequest, -) -> Result<(), String> { - state - .announcement_scheduler - .mark_seen(&request.id) - .await - .map_err(|e| format!("Failed to mark announcement seen: {}", e)) -} - -/// Dismiss a card for the current version cycle (closed without acting). -/// -/// Dismissed cards will not reappear until a version upgrade. -#[tauri::command] -pub async fn dismiss_announcement( - state: State<'_, AppState>, - request: AnnouncementIdRequest, -) -> Result<(), String> { - state - .announcement_scheduler - .dismiss(&request.id) - .await - .map_err(|e| format!("Failed to dismiss announcement: {}", e)) -} - -/// Permanently suppress a card (user selected "don't show again"). -#[tauri::command] -pub async fn never_show_announcement( - state: State<'_, AppState>, - request: AnnouncementIdRequest, -) -> Result<(), String> { - state - .announcement_scheduler - .never_show(&request.id) - .await - .map_err(|e| format!("Failed to suppress announcement: {}", e)) -} - -/// Manually trigger a specific card by ID (e.g. from a "What's New" menu item). -/// -/// Returns `None` if no card with the given ID is registered. -#[tauri::command] -pub async fn trigger_announcement( - state: State<'_, AppState>, - request: AnnouncementIdRequest, -) -> Result, String> { - let locale = state - .config_service - .get_config::(Some("app.general.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - - Ok(state - .announcement_scheduler - .trigger_card(&request.id, &locale) - .await) -} - -/// Return all currently eligible tip cards (for a dedicated tips browser). -#[tauri::command] -pub async fn get_announcement_tips( - state: State<'_, AppState>, -) -> Result, String> { - let locale = state - .config_service - .get_config::(Some("app.general.language")) - .await - .unwrap_or_else(|_| "zh-CN".to_string()); - - // Re-use the scheduler run result but filter to tips only. - let cards = state - .announcement_scheduler - .run(&locale) - .await - .map_err(|e| format!("Failed to get tips: {}", e))?; - - let tips = cards - .into_iter() - .filter(|c| { - matches!( - c.card_type, - openbitfun_core::service::announcement::types::CardType::Tip - ) - }) - .collect(); - - Ok(tips) -} diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index a2dd3cf1cd..64bca6ac7e 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -13,9 +13,7 @@ use openbitfun_core::service::remote_ssh::{ global_port_forward_manager, init_remote_workspace_manager, PortForwardManager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager, }; -use openbitfun_core::service::{ - announcement, config, filesystem, mcp, search, token_usage, workspace, -}; +use openbitfun_core::service::{config, filesystem, mcp, search, token_usage, workspace}; use openbitfun_core::util::errors::*; use openbitfun_services_integrations::speech::{SpeechService, SpeechStoragePaths}; @@ -97,7 +95,6 @@ pub struct AppState { pub active_searches: Arc>>>, /// Cancellation flags for active file transfers (download/upload), keyed by transfer_id. pub active_transfers: Arc>>>, - pub announcement_scheduler: Arc, } impl AppState { @@ -156,17 +153,6 @@ impl AppState { })?, ); - let announcement_scheduler = Arc::new( - announcement::AnnouncementScheduler::new(&path_manager) - .await - .map_err(|e| { - OpenBitFunError::service(format!( - "Failed to initialize announcement scheduler: {}", - e - )) - })?, - ); - let miniapp_manager = Arc::new(MiniAppManager::new(path_manager.clone())); initialize_global_miniapp_manager(miniapp_manager.clone()); match miniapp_manager.mark_stale_drafts_for_cleanup().await { @@ -362,7 +348,6 @@ impl AppState { remote_workspace, active_searches: Arc::new(Mutex::new(HashMap::new())), active_transfers: Arc::new(Mutex::new(HashMap::new())), - announcement_scheduler, }; if let Some(workspace_info) = initial_workspace { diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index af1102fbd1..5b15d7fb0d 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -3,7 +3,6 @@ pub mod account_identity_api; pub mod acp_client_api; pub mod agentic_api; -pub mod announcement_api; pub mod app_state; pub mod appearance_market_api; pub mod browser_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 52b38e0f99..88168e7454 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1934,13 +1934,6 @@ pub async fn run() { api::dispatch_api::dispatch_load_transcript, api::dispatch_api::dispatch_save_transcript, // Relay self-deploy API - // Announcement / feature-demo / tips API - api::announcement_api::get_pending_announcements, - api::announcement_api::mark_announcement_seen, - api::announcement_api::dismiss_announcement, - api::announcement_api::never_show_announcement, - api::announcement_api::trigger_announcement, - api::announcement_api::get_announcement_tips, // Debug API (no-op stubs in release builds) api::debug_api::debug_devtools_available, api::debug_api::debug_element_picked, diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index 87d1b4bec2..7c65793f65 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -60,7 +60,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound package/module 归属;不得用互斥 Cargo `cfg` 隐藏未评审的 Core 能力。 - 保持轻量兼容 feature 可独立编译。本地服务 profile 为 `dispatch-store`、`terminal`、 `workspace-runtime` 和 `workspace-watch`;`remote-workspace` 只增加远程工作区 facade, - `ssh-remote` 才增加具体 SSH transport。`announcement`、`file-watch`、`git`、 + `ssh-remote` 才增加具体 SSH transport。`file-watch`、`git`、 `review-platform` 也保持独立,`service-integrations` 只是其兼容聚合。任何窄 feature 都不得直接或间接启用 `product-full`。 - `product-full` 必须显式组合自身消费的每个能力,包括 `permission`、`session-git`、 `runtime-ownership` 等产品专属 `services-core` feature。不得把这些 feature 写在依赖声明上, diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index d1888c999d..8567474781 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -132,7 +132,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound profiles are `dispatch-store`, `terminal`, `workspace-runtime`, and `workspace-watch`; `remote-workspace` adds only the remote workspace facade, while `ssh-remote` adds concrete SSH transport. Integration facades - `announcement`, `file-watch`, `git`, and `review-platform` remain independent, + `file-watch`, `git`, and `review-platform` remain independent, with `service-integrations` only their compatibility aggregate. None of these narrow features may enable `product-full` directly or transitively. - `product-full` must explicitly compose every capability it consumes, including diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index c1587f507a..165b6e1dc7 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -178,7 +178,6 @@ product-full = [ "external-sources", "plugin-runtime", "opencode-plugin-host", - "announcement", "file-watch", "workspace-watch", "ssh-remote", @@ -430,11 +429,10 @@ canvas-runtime = [ ] runtime-services = ["dep:openbitfun-runtime-services"] legacy-migration = ["dep:openbitfun-legacy-migration-adapters"] -announcement = ["openbitfun-services-integrations/announcement"] file-watch = ["openbitfun-services-integrations/file-watch"] git = ["openbitfun-services-integrations/git"] review-platform = ["openbitfun-services-integrations/review-platform"] -service-integrations = ["announcement", "file-watch", "git", "review-platform"] +service-integrations = ["file-watch", "git", "review-platform"] diagnostics = ["openbitfun-services-core/diagnostics"] diff = ["openbitfun-services-core/diff"] dispatch-store = ["dep:base64", "local-storage", "openbitfun-services-core/dispatch-workspace"] diff --git a/src/crates/assembly/core/build.rs b/src/crates/assembly/core/build.rs index 9f7e551c4c..4087b3a7f3 100644 --- a/src/crates/assembly/core/build.rs +++ b/src/crates/assembly/core/build.rs @@ -5,12 +5,6 @@ fn main() { eprintln!("Warning: Failed to embed built-in skills metadata: {}", e); } } - - if std::env::var_os("CARGO_FEATURE_ANNOUNCEMENT").is_some() { - if let Err(e) = embed_announcement_content() { - eprintln!("Warning: Failed to embed announcement content: {}", e); - } - } } fn build_embedded_builtin_skills_metadata() -> Result<(), Box> { @@ -73,12 +67,6 @@ fn build_embedded_builtin_skills_metadata() -> Result<(), Box String { - // To avoid quote issues, return the original string directly - // Using r### syntax can include any character - s.to_string() -} - fn emit_rerun_if_changed(path: &std::path::Path) { if !path.exists() { return; @@ -97,119 +85,3 @@ fn emit_rerun_if_changed(path: &std::path::Path) { } } } - -/// Embed announcement MD content (tips + feature cards) from the content/ directory. -/// -/// Scans `src/service/announcement/content/{tips,features}/{locale}/*.md` and generates -/// `embedded_announcements.rs` in OUT_DIR with a static HashMap keyed by -/// `"{type}/{locale}/{stem}"` (e.g. `"tips/zh-CN/vibe_describe_task"`). -fn embed_announcement_content() -> Result<(), Box> { - use std::collections::HashMap; - use std::path::Path; - - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; - let content_root = Path::new(&manifest_dir) - .join("src") - .join("service") - .join("announcement") - .join("content"); - - println!("cargo:rerun-if-changed={}", content_root.display()); - emit_rerun_if_changed(&content_root); - - let mut entries: HashMap = HashMap::new(); - - for category in &["tips", "features"] { - let cat_dir = content_root.join(category); - if !cat_dir.exists() { - continue; - } - for locale_entry in std::fs::read_dir(&cat_dir)?.flatten() { - let locale_path = locale_entry.path(); - if !locale_path.is_dir() { - continue; - } - let locale = locale_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - for file_entry in std::fs::read_dir(&locale_path)?.flatten() { - let file_path = file_entry.path(); - if file_path.extension().and_then(|e| e.to_str()) != Some("md") { - continue; - } - let stem = file_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - // Strip leading numeric prefix (e.g. "001_") to use bare id as key. - let key_stem = if stem.len() > 4 - && stem.chars().take(3).all(|c| c.is_ascii_digit()) - && stem.chars().nth(3) == Some('_') - { - stem[4..].to_string() - } else { - stem - }; - let key = format!("{}/{}/{}", category, locale, key_stem); - let content = std::fs::read_to_string(&file_path)?; - entries.insert(key, content); - } - } - } - - generate_embedded_announcements_code(&entries) -} - -fn generate_embedded_announcements_code( - entries: &std::collections::HashMap, -) -> Result<(), Box> { - use std::fs; - use std::io::Write; - use std::path::Path; - - let out_dir = std::env::var("OUT_DIR")?; - let dest_path = Path::new(&out_dir).join("embedded_announcements.rs"); - let mut file = fs::File::create(&dest_path)?; - - writeln!( - file, - "// Embedded announcement content (auto-generated by build.rs)" - )?; - writeln!(file, "// Do not edit manually.")?; - writeln!(file)?; - writeln!(file, "use std::collections::HashMap;")?; - writeln!(file, "use std::sync::LazyLock;")?; - writeln!(file)?; - writeln!( - file, - "pub static EMBEDDED_ANNOUNCEMENTS: LazyLock> = LazyLock::new(|| {{" - )?; - writeln!(file, " let mut map = HashMap::new();")?; - - // Sort keys for deterministic generated output (see prompts codegen above). - let mut sorted: Vec<(&String, &String)> = entries.iter().collect(); - sorted.sort_by(|a, b| a.0.cmp(b.0)); - for (key, content) in sorted { - writeln!( - file, - " map.insert(r###\"{}\"###, r###\"{}\"###);", - escape_rust_string(key), - escape_rust_string(content) - )?; - } - - writeln!(file, " map")?; - writeln!(file, "}});")?; - writeln!(file)?; - writeln!( - file, - "pub fn get_announcement_content(key: &str) -> Option<&'static str> {{" - )?; - writeln!(file, " EMBEDDED_ANNOUNCEMENTS.get(key).copied()")?; - writeln!(file, "}}")?; - - Ok(()) -} diff --git a/src/crates/assembly/core/builtin_skills/create-openbitfun-skin/references/appearance-registry.json b/src/crates/assembly/core/builtin_skills/create-openbitfun-skin/references/appearance-registry.json index 5abb423ded..efdadf3908 100644 --- a/src/crates/assembly/core/builtin_skills/create-openbitfun-skin/references/appearance-registry.json +++ b/src/crates/assembly/core/builtin_skills/create-openbitfun-skin/references/appearance-registry.json @@ -4978,58 +4978,6 @@ } ] }, - { - "id": "announcement", - "parts": [ - { - "id": "stack", - "propertyProfile": "container" - }, - { - "id": "deck", - "propertyProfile": "container" - }, - { - "id": "ghost", - "propertyProfile": "container" - }, - { - "id": "modalBackdrop", - "propertyProfile": "container" - }, - { - "id": "modal", - "propertyProfile": "container" - }, - { - "id": "modalClose", - "propertyProfile": "container" - }, - { - "id": "modalPages", - "propertyProfile": "container" - }, - { - "id": "modalFooter", - "propertyProfile": "container" - }, - { - "id": "modalNavigation", - "propertyProfile": "container" - } - ], - "facets": [ - { - "id": "depth", - "attribute": "data-openbitfun-depth", - "values": [ - "1", - "2" - ] - } - ], - "states": [] - }, { "id": "context-menu", "parts": [ diff --git a/src/crates/assembly/core/src/service/announcement/content/features/en-US/release_letter_1_0_0.md b/src/crates/assembly/core/src/service/announcement/content/features/en-US/release_letter_1_0_0.md deleted file mode 100644 index 365bec0b56..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/features/en-US/release_letter_1_0_0.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: release_letter_1_0_0 -card_type: announcement -app_version: 1.0.0 -trigger: always -once_per_version: true -delay_ms: 0 -toast_title: OpenBitFun 1.0.0 -toast_desc: To every friend who supports OpenBitFun -modal_size: xl -modal_presentation: release_letter -completion_action: dismiss -priority: 1000 ---- - -# To every friend who supports OpenBitFun - -OpenBitFun 1.0.0 is here. - -Every star, every piece of feedback, and every contribution has helped us get here. Thank you. - -From Rust Agent Runtime and Agent Harness to the GUI, we have kept refining every detail with one wish in mind: to help more people build what they have always wanted to create. - -1.0.0 brings four Agent Harness options, each fully capable in its own right. Choose the one that best fits the task at hand. From computers to phones, watches, and browsers—and eventually glasses—we want OpenBitFun to feel closer to your everyday life. Whenever you want to make something, it will be within reach, helping you turn an idea, step by step, into something real. - -We will stay true to the spirit of open source and keep making OpenBitFun more reliable and easier to use. In more people's hands, we hope it will become something we cannot yet imagine. - -**Create with it. And help create what comes next.** diff --git a/src/crates/assembly/core/src/service/announcement/content/features/zh-CN/release_letter_1_0_0.md b/src/crates/assembly/core/src/service/announcement/content/features/zh-CN/release_letter_1_0_0.md deleted file mode 100644 index 62d793502c..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/features/zh-CN/release_letter_1_0_0.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: release_letter_1_0_0 -card_type: announcement -app_version: 1.0.0 -trigger: always -once_per_version: true -delay_ms: 0 -toast_title: OpenBitFun 1.0.0 -toast_desc: 致每一位支持 OpenBitFun 的朋友 -modal_size: xl -modal_presentation: release_letter -completion_action: dismiss -priority: 1000 ---- - -# 致每一位支持 OpenBitFun 的朋友 - -OpenBitFun 1.0.0,全新发布。 - -感谢每一颗 Star、每一次反馈与贡献,陪 OpenBitFun 走到今天。 - -从 Rust Agent Runtime、Agent Harness 到 GUI,我们反复打磨, -始终为了一个愿望:让更多人把一直想做的事做出来。 - -1.0.0 带来四种 Agent Harness,每一种都能独当一面,你可以根据手头的事,选择最合适的那一种。 -从电脑到手机、手表和浏览器,再到未来的眼镜,我们想让 OpenBitFun 离你的生活更近一点。 -当你想做些什么的时候,它就在手边,陪你把一个念头,慢慢变成看得见的成果。 - -我们会继续奉行开源精神,把它做得更可靠、更好用。未来的 OpenBitFun, -也期待在更多人的手里,成为今天还想不到的模样。 - -**用它创造,也一起创造它。** diff --git a/src/crates/assembly/core/src/service/announcement/content/features/zh-TW/release_letter_1_0_0.md b/src/crates/assembly/core/src/service/announcement/content/features/zh-TW/release_letter_1_0_0.md deleted file mode 100644 index ee77c33bbc..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/features/zh-TW/release_letter_1_0_0.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -id: release_letter_1_0_0 -card_type: announcement -app_version: 1.0.0 -trigger: always -once_per_version: true -delay_ms: 0 -toast_title: OpenBitFun 1.0.0 -toast_desc: 致每一位支持 OpenBitFun 的朋友 -modal_size: xl -modal_presentation: release_letter -completion_action: dismiss -priority: 1000 ---- - -# 致每一位支持 OpenBitFun 的朋友 - -OpenBitFun 1.0.0,全新發佈。 - -感謝每一顆 Star、每一次回饋與貢獻,陪 OpenBitFun 走到今天。 - -從 Rust Agent Runtime、Agent Harness 到 GUI,我們反覆打磨, -始終為了一個願望:讓更多人把一直想做的事做出來。 - -1.0.0 帶來四種 Agent Harness,每一種都能獨當一面,你可以根據手頭的事,選擇最合適的那一種。 -從電腦到手機、手錶和瀏覽器,再到未來的眼鏡,我們想讓 OpenBitFun 離你的生活更近一點。 -當你想做些什麼的時候,它就在手邊,陪你把一個念頭,慢慢變成看得見的成果。 - -我們會繼續奉行開源精神,把它做得更可靠、更好用。未來的 OpenBitFun, -也期待在更多人的手裡,成為今天還想不到的模樣。 - -**用它創造,也一起創造它。** diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/001_vibe_describe_task.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/001_vibe_describe_task.md deleted file mode 100644 index 6888c18423..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/001_vibe_describe_task.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_describe_task -nth_open: 2 -auto_dismiss_secs: 12 ---- - -# Describe the goal, not the steps - -Tell AI what you want to achieve, not how to implement it — let AI choose the best path diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/002_quick_scene_switch.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/002_quick_scene_switch.md deleted file mode 100644 index 69964b375e..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/002_quick_scene_switch.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: quick_scene_switch -nth_open: 3 -auto_dismiss_secs: 10 ---- - -# Quick scene switch - -Press Alt+1/2/3 to switch between Chat, Editor and Settings scenes instantly diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/003_vibe_file_reference.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/003_vibe_file_reference.md deleted file mode 100644 index bf6a16b372..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/003_vibe_file_reference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_file_reference -nth_open: 4 -auto_dismiss_secs: 12 ---- - -# Reference files with @ - -Type @ or drag a file into the chat box — AI will read only the relevant code diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/004_slash_commands.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/004_slash_commands.md deleted file mode 100644 index 3699969665..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/004_slash_commands.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: slash_commands -nth_open: 5 -auto_dismiss_secs: 10 ---- - -# Slash commands - -Type / in the chat box to open the command menu and invoke tools quickly diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/005_vibe_break_tasks.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/005_vibe_break_tasks.md deleted file mode 100644 index c9663a9c67..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/005_vibe_break_tasks.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_break_tasks -nth_open: 6 -auto_dismiss_secs: 12 ---- - -# Break big tasks into steps - -Split complex requirements across multiple turns — confirm each step before moving on diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/006_drag_file_context.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/006_drag_file_context.md deleted file mode 100644 index b87941f207..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/006_drag_file_context.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: drag_file_context -nth_open: 7 -auto_dismiss_secs: 10 ---- - -# Drag to add context - -Drag any file into the chat box and AI will read its contents automatically diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/007_vibe_iterate.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/007_vibe_iterate.md deleted file mode 100644 index a1bc2856f9..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/007_vibe_iterate.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_iterate -nth_open: 8 -auto_dismiss_secs: 12 ---- - -# Iteration is the workflow - -AI's first output is a starting point — refine with "improve this" or "make it more X" diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/008_git_ai_commit.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/008_git_ai_commit.md deleted file mode 100644 index 7a2b47ddb0..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/008_git_ai_commit.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: git_ai_commit -nth_open: 10 -auto_dismiss_secs: 10 ---- - -# AI commit message - -Stage your changes in the Git panel, then click the magic wand to auto-generate a commit message diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/009_vibe_multi_turn.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/009_vibe_multi_turn.md deleted file mode 100644 index 66a674c6a3..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/009_vibe_multi_turn.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_multi_turn -nth_open: 11 -auto_dismiss_secs: 12 ---- - -# Keep the conversation going - -Following up in the same session is faster than starting fresh — AI remembers full context diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/010_multi_model.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/010_multi_model.md deleted file mode 100644 index df5be1b2ac..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/010_multi_model.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: multi_model -nth_open: 12 -auto_dismiss_secs: 10 ---- - -# Multiple AI models - -Configure multiple AI models in settings and choose the best one for each task diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/011_vibe_explain.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/011_vibe_explain.md deleted file mode 100644 index bbca138f26..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/011_vibe_explain.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_explain -nth_open: 13 -auto_dismiss_secs: 10 ---- - -# Ask AI to explain its reasoning - -Add "explain your approach" to any prompt — transparent reasoning helps you spot mistakes faster diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/012_vibe_context_window.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/012_vibe_context_window.md deleted file mode 100644 index c3eab216f9..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/012_vibe_context_window.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_context_window -nth_open: 14 -auto_dismiss_secs: 12 ---- - -# More context = better answers - -Include error logs, related files and expected behavior — the more detail, the more precise the output diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/013_miniapp.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/013_miniapp.md deleted file mode 100644 index 8a91e842c4..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/013_miniapp.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: miniapp -nth_open: 15 -auto_dismiss_secs: 10 ---- - -# MiniApp instant apps - -Ask AI to generate a runnable mini-application directly through conversation diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/014_vibe_prompt_patterns.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/014_vibe_prompt_patterns.md deleted file mode 100644 index e3cd0b3fe9..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/014_vibe_prompt_patterns.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_prompt_patterns -nth_open: 16 -auto_dismiss_secs: 12 ---- - -# Effective prompt patterns - -"As [role], do [task], with [constraint]" — the three-part structure dramatically improves AI output quality diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/015_snapshot_rollback.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/015_snapshot_rollback.md deleted file mode 100644 index dedf024fc9..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/015_snapshot_rollback.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: snapshot_rollback -nth_open: 18 -auto_dismiss_secs: 10 ---- - -# Snapshots make you fearless - -Every turn creates a snapshot — let AI refactor boldly and roll back instantly if needed diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/016_vibe_review_output.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/016_vibe_review_output.md deleted file mode 100644 index db53116a83..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/016_vibe_review_output.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_review_output -nth_open: 20 -auto_dismiss_secs: 12 ---- - -# Read before you accept - -Make it a habit: read every change before accepting — stay in control of your codebase diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/017_terminal_shortcut.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/017_terminal_shortcut.md deleted file mode 100644 index b069795db7..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/017_terminal_shortcut.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: terminal_shortcut -nth_open: 22 -auto_dismiss_secs: 10 ---- - -# Terminal shortcuts - -Select terminal output and send it directly to AI for analysis diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/021_session_history.md b/src/crates/assembly/core/src/service/announcement/content/tips/en-US/021_session_history.md deleted file mode 100644 index 73c4a403fb..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/en-US/021_session_history.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: session_history -nth_open: 40 -auto_dismiss_secs: 10 ---- - -# Session history - -All conversations are persisted — reopen any previous session from the history panel diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/001_vibe_describe_task.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/001_vibe_describe_task.md deleted file mode 100644 index 686b540147..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/001_vibe_describe_task.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_describe_task -nth_open: 2 -auto_dismiss_secs: 12 ---- - -# 描述目标,不是步骤 - -告诉 AI「你想要什么结果」而非「怎么实现」,让 AI 决定最佳路径 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/002_quick_scene_switch.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/002_quick_scene_switch.md deleted file mode 100644 index 0da7277c3a..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/002_quick_scene_switch.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: quick_scene_switch -nth_open: 3 -auto_dismiss_secs: 10 ---- - -# 快速切换场景 - -按 Alt+1/2/3 可在聊天、编辑器、设置等场景间快速切换 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/003_vibe_file_reference.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/003_vibe_file_reference.md deleted file mode 100644 index 1b76675fe3..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/003_vibe_file_reference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_file_reference -nth_open: 4 -auto_dismiss_secs: 12 ---- - -# @ 引用文件 - -在聊天框输入 @ 或直接拖入文件,AI 会精准读取相关代码 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/004_slash_commands.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/004_slash_commands.md deleted file mode 100644 index 7eecadbb97..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/004_slash_commands.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: slash_commands -nth_open: 5 -auto_dismiss_secs: 10 ---- - -# 斜杠命令 - -在聊天框输入 / 触发命令菜单,快速调用工具 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/005_vibe_break_tasks.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/005_vibe_break_tasks.md deleted file mode 100644 index aee7847f3d..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/005_vibe_break_tasks.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_break_tasks -nth_open: 6 -auto_dismiss_secs: 12 ---- - -# 大任务拆小步 - -将复杂需求分成多轮对话逐步实现,每步确认无误再继续 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/006_drag_file_context.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/006_drag_file_context.md deleted file mode 100644 index 85e333a335..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/006_drag_file_context.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: drag_file_context -nth_open: 7 -auto_dismiss_secs: 10 ---- - -# 添加本机文件上下文 - -在 Desktop 本地会话中,可将本机文件或文件夹复制并粘贴到对话框;支持本机路径拖放的平台也可直接拖入 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/007_vibe_iterate.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/007_vibe_iterate.md deleted file mode 100644 index c3a0ce14e5..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/007_vibe_iterate.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_iterate -nth_open: 8 -auto_dismiss_secs: 12 ---- - -# 迭代是常态 - -AI 的第一次输出是起点,用「再优化一下」「改成 X 风格」持续迭代 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/008_git_ai_commit.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/008_git_ai_commit.md deleted file mode 100644 index a8b8fbb7de..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/008_git_ai_commit.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: git_ai_commit -nth_open: 10 -auto_dismiss_secs: 10 ---- - -# AI 生成提交信息 - -在 Git 面板暂存更改后,点击魔法棒自动生成提交信息 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/009_vibe_multi_turn.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/009_vibe_multi_turn.md deleted file mode 100644 index f70b4e943e..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/009_vibe_multi_turn.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_multi_turn -nth_open: 11 -auto_dismiss_secs: 12 ---- - -# 保持对话连贯 - -在同一会话中追问比新开对话更高效,AI 能记住完整上下文 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/010_multi_model.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/010_multi_model.md deleted file mode 100644 index 68125d4875..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/010_multi_model.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: multi_model -nth_open: 12 -auto_dismiss_secs: 10 ---- - -# 多模型切换 - -在设置中配置多个 AI 模型,根据任务选择最合适的模型 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/011_vibe_explain.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/011_vibe_explain.md deleted file mode 100644 index 02d42d24ed..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/011_vibe_explain.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_explain -nth_open: 13 -auto_dismiss_secs: 10 ---- - -# 让 AI 解释推理 - -加上「解释一下你的思路」,AI 的输出更透明,你也能更快发现错误 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/012_vibe_context_window.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/012_vibe_context_window.md deleted file mode 100644 index 0c089c7771..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/012_vibe_context_window.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_context_window -nth_open: 14 -auto_dismiss_secs: 12 ---- - -# 上下文越丰富越好 - -附上错误日志、相关文件、期望行为——细节越多,AI 答得越准 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/013_miniapp.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/013_miniapp.md deleted file mode 100644 index 6cfcc60e22..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/013_miniapp.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: miniapp -nth_open: 15 -auto_dismiss_secs: 10 ---- - -# MiniApp 即时应用 - -通过对话让 AI 直接生成可运行的小应用 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/014_vibe_prompt_patterns.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/014_vibe_prompt_patterns.md deleted file mode 100644 index 80a1291eaa..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/014_vibe_prompt_patterns.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_prompt_patterns -nth_open: 16 -auto_dismiss_secs: 12 ---- - -# 高效提示词模式 - -「作为…角色,完成…任务,要求…约束」三段式写法能显著提升 AI 输出质量 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/015_snapshot_rollback.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/015_snapshot_rollback.md deleted file mode 100644 index 36eb143eba..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/015_snapshot_rollback.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: snapshot_rollback -nth_open: 18 -auto_dismiss_secs: 10 ---- - -# 快照让你无所畏惧 - -每次对话后自动快照,放心让 AI 大胆重构,随时一键回滚 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/016_vibe_review_output.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/016_vibe_review_output.md deleted file mode 100644 index d011816be5..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/016_vibe_review_output.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_review_output -nth_open: 20 -auto_dismiss_secs: 12 ---- - -# 先读懂再接受 - -养成习惯:accept 代码前先通读,理解每一处改动,保持对代码库的掌控 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/017_terminal_shortcut.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/017_terminal_shortcut.md deleted file mode 100644 index 5ead94012c..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/017_terminal_shortcut.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: terminal_shortcut -nth_open: 22 -auto_dismiss_secs: 10 ---- - -# 终端快捷操作 - -在终端面板选中命令输出,可直接发送给 AI 分析 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/021_session_history.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/021_session_history.md deleted file mode 100644 index ea0511d80c..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-CN/021_session_history.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: session_history -nth_open: 40 -auto_dismiss_secs: 10 ---- - -# 会话历史 - -所有对话历史均已持久化,可随时在历史面板中重新加载 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/001_vibe_describe_task.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/001_vibe_describe_task.md deleted file mode 100644 index e2355cf73e..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/001_vibe_describe_task.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_describe_task -nth_open: 2 -auto_dismiss_secs: 12 ---- - -# 描述目標,不是步驟 - -告訴 AI「你想要什麼結果」而非「怎麼實現」,讓 AI 決定最佳路徑 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/002_quick_scene_switch.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/002_quick_scene_switch.md deleted file mode 100644 index 42dba6fdc5..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/002_quick_scene_switch.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: quick_scene_switch -nth_open: 3 -auto_dismiss_secs: 10 ---- - -# 快速切換場景 - -按 Alt+1/2/3 可在聊天、編輯器、設置等場景間快速切換 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/003_vibe_file_reference.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/003_vibe_file_reference.md deleted file mode 100644 index 9d15c90256..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/003_vibe_file_reference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_file_reference -nth_open: 4 -auto_dismiss_secs: 12 ---- - -# @ 引用文件 - -在聊天框輸入 @ 或直接拖入文件,AI 會精準讀取相關代碼 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/004_slash_commands.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/004_slash_commands.md deleted file mode 100644 index e14b73893e..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/004_slash_commands.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: slash_commands -nth_open: 5 -auto_dismiss_secs: 10 ---- - -# 斜槓命令 - -在聊天框輸入 / 觸發命令菜單,快速調用工具 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/005_vibe_break_tasks.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/005_vibe_break_tasks.md deleted file mode 100644 index 46d6af3f5d..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/005_vibe_break_tasks.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_break_tasks -nth_open: 6 -auto_dismiss_secs: 12 ---- - -# 大任務拆小步 - -將複雜需求分成多輪對話逐步實現,每步確認無誤再繼續 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/006_drag_file_context.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/006_drag_file_context.md deleted file mode 100644 index a528b572c0..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/006_drag_file_context.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: drag_file_context -nth_open: 7 -auto_dismiss_secs: 10 ---- - -# 拖拽添加上下文 - -直接將文件拖入對話框,AI 將自動讀取文件內容 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/007_vibe_iterate.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/007_vibe_iterate.md deleted file mode 100644 index f03616bdae..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/007_vibe_iterate.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_iterate -nth_open: 8 -auto_dismiss_secs: 12 ---- - -# 迭代是常態 - -AI 的第一次輸出是起點,用「再優化一下」「改成 X 風格」持續迭代 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/008_git_ai_commit.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/008_git_ai_commit.md deleted file mode 100644 index e5506b9662..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/008_git_ai_commit.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: git_ai_commit -nth_open: 10 -auto_dismiss_secs: 10 ---- - -# AI 生成提交信息 - -在 Git 面板暫存更改後,點擊魔法棒自動生成提交信息 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/009_vibe_multi_turn.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/009_vibe_multi_turn.md deleted file mode 100644 index 56f076f559..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/009_vibe_multi_turn.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_multi_turn -nth_open: 11 -auto_dismiss_secs: 12 ---- - -# 保持對話連貫 - -在同一會話中追問比新開對話更高效,AI 能記住完整上下文 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/010_multi_model.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/010_multi_model.md deleted file mode 100644 index e43cf39314..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/010_multi_model.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: multi_model -nth_open: 12 -auto_dismiss_secs: 10 ---- - -# 多模型切換 - -在設置中配置多個 AI 模型,根據任務選擇最合適的模型 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/011_vibe_explain.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/011_vibe_explain.md deleted file mode 100644 index 818b1d6c0c..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/011_vibe_explain.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_explain -nth_open: 13 -auto_dismiss_secs: 10 ---- - -# 讓 AI 解釋推理 - -加上「解釋一下你的思路」,AI 的輸出更透明,你也能更快發現錯誤 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/012_vibe_context_window.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/012_vibe_context_window.md deleted file mode 100644 index e6a916f2cd..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/012_vibe_context_window.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_context_window -nth_open: 14 -auto_dismiss_secs: 12 ---- - -# 上下文越豐富越好 - -附上錯誤日誌、相關文件、期望行為——細節越多,AI 答得越準 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/013_miniapp.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/013_miniapp.md deleted file mode 100644 index e4e12d6343..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/013_miniapp.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: miniapp -nth_open: 15 -auto_dismiss_secs: 10 ---- - -# MiniApp 即時應用 - -通過對話讓 AI 直接生成可運行的小應用 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/014_vibe_prompt_patterns.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/014_vibe_prompt_patterns.md deleted file mode 100644 index 285bf6ab77..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/014_vibe_prompt_patterns.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_prompt_patterns -nth_open: 16 -auto_dismiss_secs: 12 ---- - -# 高效提示詞模式 - -「作為…角色,完成…任務,要求…約束」三段式寫法能顯著提升 AI 輸出質量 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/015_snapshot_rollback.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/015_snapshot_rollback.md deleted file mode 100644 index b617f84cf7..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/015_snapshot_rollback.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: snapshot_rollback -nth_open: 18 -auto_dismiss_secs: 10 ---- - -# 快照讓你無所畏懼 - -每次對話後自動快照,放心讓 AI 大膽重構,隨時一鍵回滾 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/016_vibe_review_output.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/016_vibe_review_output.md deleted file mode 100644 index fb96eeb99a..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/016_vibe_review_output.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: vibe_review_output -nth_open: 20 -auto_dismiss_secs: 12 ---- - -# 先讀懂再接受 - -養成習慣:accept 代碼前先通讀,理解每一處改動,保持對代碼庫的掌控 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/017_terminal_shortcut.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/017_terminal_shortcut.md deleted file mode 100644 index aeb365799a..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/017_terminal_shortcut.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: terminal_shortcut -nth_open: 22 -auto_dismiss_secs: 10 ---- - -# 終端快捷操作 - -在終端面板選中命令輸出,可直接發送給 AI 分析 diff --git a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/021_session_history.md b/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/021_session_history.md deleted file mode 100644 index cbb168e19d..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content/tips/zh-TW/021_session_history.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: session_history -nth_open: 40 -auto_dismiss_secs: 10 ---- - -# 會話歷史 - -所有對話歷史均已持久化,可隨時在歷史面板中重新加載 diff --git a/src/crates/assembly/core/src/service/announcement/content_loader.rs b/src/crates/assembly/core/src/service/announcement/content_loader.rs deleted file mode 100644 index 0314656656..0000000000 --- a/src/crates/assembly/core/src/service/announcement/content_loader.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! Announcement content loader. -//! -//! Loads tip and feature-card content from Markdown files that were embedded -//! into the binary at compile time by `build.rs`. -//! -//! Each file uses YAML front matter (delimited by `---`) followed by Markdown -//! body text: -//! -//! - **Tip files** (`tips/{locale}/NNN_id.md`): front matter carries `id`, -//! `nth_open`, `auto_dismiss_secs`; the first `# Heading` becomes the toast -//! title and the remaining text becomes the toast description. -//! -//! - **Feature files** (`features/{locale}/id.md`): front matter carries toast -//! metadata and modal settings; `` comments split the body into -//! separate modal pages, each with its own `# Heading` title. - -use super::types::{ - AnnouncementCard, CardSource, CardType, CompletionAction, ModalConfig, ModalPage, - ModalPresentation, ModalSize, PageLayout, ToastConfig, TriggerCondition, TriggerRule, -}; - -include!(concat!(env!("OUT_DIR"), "/embedded_announcements.rs")); - -const FALLBACK_LOCALE: &str = "en-US"; - -// ─── Front matter ──────────────────────────────────────────────────────────── - -/// Minimal front matter for a tip card. -#[derive(Debug)] -struct TipFrontMatter { - id: String, - nth_open: u64, - auto_dismiss_secs: u64, -} - -/// Minimal front matter for a feature card. -#[derive(Debug)] -struct FeatureFrontMatter { - id: String, - /// `feature` | `news` | `announcement` (default: feature) - card_type: String, - /// Exact application version for which this card is eligible. - app_version: Option, - /// `version_first_open` | `always` | `manual` (default: version_first_open) - trigger: String, - once_per_version: bool, - delay_ms: u64, - toast_title: String, - toast_desc: String, - modal_size: String, - /// `standard` | `release_letter` (default: standard) - modal_presentation: String, - completion_action: String, - auto_dismiss_ms: Option, - priority: i32, -} - -/// Split raw file content into (front_matter_text, body_text). -/// Returns `None` if the file does not start with `---`. -fn split_front_matter(src: &str) -> Option<(&str, &str)> { - let src = src.trim_start(); - if !src.starts_with("---") { - return None; - } - let after_open = &src[3..]; - // Skip optional newline immediately after opening `---` - let after_open = after_open - .trim_start_matches('\n') - .trim_start_matches("\r\n"); - let close = after_open.find("\n---")?; - let fm = &after_open[..close]; - let body = &after_open[close + 4..]; // skip "\n---" - Some((fm, body)) -} - -/// Parse a simple `key: value` YAML line. -fn parse_kv(line: &str) -> Option<(&str, &str)> { - let idx = line.find(':')?; - let key = line[..idx].trim(); - let val = line[idx + 1..].trim().trim_matches('"'); - Some((key, val)) -} - -fn parse_tip_front_matter(fm: &str) -> Option { - let mut id = String::new(); - let mut nth_open: u64 = 1; - let mut auto_dismiss_secs: u64 = 10; - - for line in fm.lines() { - if let Some((k, v)) = parse_kv(line) { - match k { - "id" => id = v.to_string(), - "nth_open" => nth_open = v.parse().unwrap_or(1), - "auto_dismiss_secs" => auto_dismiss_secs = v.parse().unwrap_or(10), - _ => {} - } - } - } - - if id.is_empty() { - return None; - } - Some(TipFrontMatter { - id, - nth_open, - auto_dismiss_secs, - }) -} - -fn parse_feature_front_matter(fm: &str) -> Option { - let mut id = String::new(); - let mut card_type = "feature".to_string(); - let mut app_version: Option = None; - let mut trigger = "version_first_open".to_string(); - let mut once_per_version = true; - let mut delay_ms: u64 = 2000; - let mut toast_title = String::new(); - let mut toast_desc = String::new(); - let mut modal_size = "lg".to_string(); - let mut modal_presentation = "standard".to_string(); - let mut completion_action = "never_show_again".to_string(); - let mut auto_dismiss_ms: Option = None; - let mut priority: i32 = 0; - - for line in fm.lines() { - if let Some((k, v)) = parse_kv(line) { - match k { - "id" => id = v.to_string(), - "card_type" => card_type = v.to_string(), - "app_version" => { - app_version = (!v.is_empty()).then(|| v.to_string()); - } - "trigger" => trigger = v.to_string(), - "once_per_version" => once_per_version = v == "true", - "delay_ms" => delay_ms = v.parse().unwrap_or(2000), - "toast_title" => toast_title = v.to_string(), - "toast_desc" => toast_desc = v.to_string(), - "modal_size" => modal_size = v.to_string(), - "modal_presentation" => modal_presentation = v.to_string(), - "completion_action" => completion_action = v.to_string(), - "auto_dismiss_ms" => auto_dismiss_ms = v.parse().ok(), - "priority" => priority = v.parse().unwrap_or(0), - _ => {} - } - } - } - - if id.is_empty() || toast_title.is_empty() { - return None; - } - Some(FeatureFrontMatter { - id, - card_type, - app_version, - trigger, - once_per_version, - delay_ms, - toast_title, - toast_desc, - modal_size, - modal_presentation, - completion_action, - auto_dismiss_ms, - priority, - }) -} - -// ─── Body parsing ───────────────────────────────────────────────────────────── - -/// Parse a page body: extract the first `# Heading` as title, the rest as body. -/// Returns (title, body_markdown). -fn parse_page_body(text: &str) -> (String, String) { - let text = text.trim(); - let mut lines = text.lines(); - let mut title = String::new(); - let mut body_lines: Vec<&str> = Vec::new(); - let mut found_title = false; - - for line in &mut lines { - if !found_title && line.starts_with("# ") { - title = line[2..].trim().to_string(); - found_title = true; - } else { - body_lines.push(line); - } - } - - let body = body_lines.join("\n").trim().to_string(); - (title, body) -} - -/// Split feature body into pages using `` as the delimiter. -fn split_pages(body: &str) -> Vec<&str> { - body.split("").collect() -} - -// ─── Card builders ──────────────────────────────────────────────────────────── - -fn build_tip_card(fm: TipFrontMatter, body: &str) -> AnnouncementCard { - let (title, desc) = parse_page_body(body); - AnnouncementCard { - id: format!("tip_{}", fm.id), - card_type: CardType::Tip, - source: CardSource::BuiltinTip, - app_version: None, - priority: -10, - trigger: TriggerRule { - condition: TriggerCondition::AppNthOpen { n: fm.nth_open }, - delay_ms: 5000, - once_per_version: false, - }, - toast: ToastConfig { - icon: String::new(), - title, - description: desc, - action_label: "announcements.common.got_it".to_string(), - dismissible: true, - auto_dismiss_ms: Some(fm.auto_dismiss_secs * 1000), - }, - modal: None, - expires_at: None, - } -} - -fn build_feature_card(fm: FeatureFrontMatter, body: &str) -> AnnouncementCard { - let card_type = match fm.card_type.as_str() { - "announcement" => CardType::Announcement, - "news" => CardType::News, - _ => CardType::Feature, - }; - - let trigger_condition = match fm.trigger.as_str() { - "always" => TriggerCondition::Always, - "manual" => TriggerCondition::Manual, - _ => TriggerCondition::VersionFirstOpen, - }; - - let modal_size = match fm.modal_size.as_str() { - "sm" => ModalSize::Sm, - "md" => ModalSize::Md, - "xl" => ModalSize::Xl, - _ => ModalSize::Lg, - }; - - let modal_presentation = match fm.modal_presentation.as_str() { - "release_letter" => ModalPresentation::ReleaseLetter, - _ => ModalPresentation::Standard, - }; - - let completion_action = match fm.completion_action.as_str() { - "dismiss" => CompletionAction::Dismiss, - _ => CompletionAction::NeverShowAgain, - }; - - let pages: Vec = split_pages(body) - .into_iter() - .map(|page_text| { - let (title, body_md) = parse_page_body(page_text); - ModalPage { - layout: PageLayout::TextOnly, - title, - body: body_md, - media: None, - } - }) - .filter(|p| !p.title.is_empty()) - .collect(); - - AnnouncementCard { - id: fm.id, - card_type, - source: CardSource::Local, - app_version: fm.app_version, - priority: fm.priority, - trigger: TriggerRule { - condition: trigger_condition, - delay_ms: fm.delay_ms, - once_per_version: fm.once_per_version, - }, - toast: ToastConfig { - icon: String::new(), - title: fm.toast_title, - description: fm.toast_desc, - action_label: "announcements.common.learn_more".to_string(), - dismissible: true, - auto_dismiss_ms: fm.auto_dismiss_ms, - }, - modal: if pages.is_empty() { - None - } else { - Some(ModalConfig { - size: modal_size, - presentation: modal_presentation, - closable: true, - completion_action, - pages, - }) - }, - expires_at: None, - } -} - -// ─── Public API ─────────────────────────────────────────────────────────────── - -/// Resolve the best available locale key prefix: tries `locale` first, then -/// falls back to `FALLBACK_LOCALE`. -fn resolve_locale<'a>(category: &str, locale: &'a str) -> &'a str { - let probe = format!("{}/{}/", category, locale); - let has_any = EMBEDDED_ANNOUNCEMENTS - .keys() - .any(|k| k.starts_with(probe.as_str())); - if has_any { - locale - } else { - FALLBACK_LOCALE - } -} - -/// Load all built-in tip cards for the given locale. -/// -/// Falls back to `en-US` if the requested locale has no tip files. -pub fn load_tips(locale: &str) -> Vec { - let effective = resolve_locale("tips", locale); - let prefix = format!("tips/{}/", effective); - - let mut cards: Vec = EMBEDDED_ANNOUNCEMENTS - .iter() - .filter(|(k, _)| k.starts_with(prefix.as_str())) - .filter_map(|(_, content)| { - let (fm_text, body) = split_front_matter(content)?; - let fm = parse_tip_front_matter(fm_text)?; - Some(build_tip_card(fm, body)) - }) - .collect(); - - // Stable sort by nth_open so tips fire in the intended order. - cards.sort_by_key(|c| { - if let TriggerCondition::AppNthOpen { n } = c.trigger.condition { - n - } else { - u64::MAX - } - }); - cards -} - -/// Load all locally registered feature cards for the given locale. -/// -/// Falls back to `en-US` if the requested locale has no feature files. -pub fn load_features(locale: &str) -> Vec { - let effective = resolve_locale("features", locale); - let prefix = format!("features/{}/", effective); - - EMBEDDED_ANNOUNCEMENTS - .iter() - .filter(|(k, _)| k.starts_with(prefix.as_str())) - .filter_map(|(_, content)| { - let (fm_text, body) = split_front_matter(content)?; - let fm = parse_feature_front_matter(fm_text)?; - Some(build_feature_card(fm, body)) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn release_letter(locale: &str) -> AnnouncementCard { - load_features(locale) - .into_iter() - .find(|card| card.id == "release_letter_1_0_0") - .expect("release letter should be embedded for every supported locale") - } - - #[test] - fn release_letter_is_an_exact_version_direct_announcement() { - let card = release_letter("zh-CN"); - - assert_eq!(card.card_type, CardType::Announcement); - assert_eq!(card.source, CardSource::Local); - assert_eq!(card.app_version.as_deref(), Some("1.0.0")); - assert_eq!(card.priority, 1000); - assert!(matches!(card.trigger.condition, TriggerCondition::Always)); - assert!(card.trigger.once_per_version); - assert_eq!(card.trigger.delay_ms, 0); - assert!(matches!( - card.modal.as_ref().map(|modal| &modal.presentation), - Some(ModalPresentation::ReleaseLetter) - )); - } - - #[test] - fn release_letter_copy_exists_for_all_supported_locales() { - let zh_cn = release_letter("zh-CN"); - let en_us = release_letter("en-US"); - let zh_tw = release_letter("zh-TW"); - - assert!(zh_cn.modal.unwrap().pages[0].body.contains("用它创造")); - assert!(en_us.modal.unwrap().pages[0] - .body - .contains("Create with it")); - assert!(zh_tw.modal.unwrap().pages[0].body.contains("用它創造")); - } -} diff --git a/src/crates/assembly/core/src/service/announcement/mod.rs b/src/crates/assembly/core/src/service/announcement/mod.rs deleted file mode 100644 index e6d5b52c47..0000000000 --- a/src/crates/assembly/core/src/service/announcement/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Announcement, feature-demo and tips system. - -pub mod content_loader; -pub mod registry; -pub mod remote; -pub mod scheduler; -pub mod state_store; -pub mod tips_pool; -pub mod types; - -pub use scheduler::{AnnouncementScheduler, AnnouncementSchedulerRef}; -pub use types::{AnnouncementCard, CardType}; diff --git a/src/crates/assembly/core/src/service/announcement/registry.rs b/src/crates/assembly/core/src/service/announcement/registry.rs deleted file mode 100644 index e7e42856e5..0000000000 --- a/src/crates/assembly/core/src/service/announcement/registry.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Local static card registry. -//! -//! Each application release can add a new Markdown file under -//! `content/features/{locale}/` to register feature announcement cards. -//! Cards are loaded at startup and matched against the running version at -//! scheduling time, so old cards are automatically ignored once the user has -//! seen them. - -use super::content_loader; -use super::types::AnnouncementCard; - -/// Returns all locally registered announcement cards for the given locale. -/// -/// Content is sourced from `content/features/{locale}/*.md` files. -/// Falls back to `en-US` if the requested locale has no feature files. -pub fn local_cards(locale: &str) -> Vec { - content_loader::load_features(locale) -} diff --git a/src/crates/assembly/core/src/service/announcement/remote.rs b/src/crates/assembly/core/src/service/announcement/remote.rs deleted file mode 100644 index a05b03ce51..0000000000 --- a/src/crates/assembly/core/src/service/announcement/remote.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Remote announcement fetcher compatibility wrapper. -//! -//! HTTP fetch, cache TTL, and cache hydration live in -//! `openbitfun-services-integrations`; core only supplies product configuration. - -use crate::infrastructure::app_paths::PathManager; -use openbitfun_services_integrations::announcement::{ - AnnouncementRemoteFetchRequest, RemoteAnnouncementFetcher, -}; -use std::sync::Arc; - -/// Cloneable handle so it can be moved into background tasks. -#[derive(Clone)] -pub struct RemoteFetcher { - inner: RemoteAnnouncementFetcher, -} - -impl RemoteFetcher { - pub fn new(path_manager: &Arc) -> Self { - let cache_file = path_manager - .user_config_dir() - .join("announcement-remote-cache.json"); - Self { - inner: RemoteAnnouncementFetcher::new(cache_file), - } - } - - /// Return the in-memory cached cards, populated from disk on first access. - pub async fn cached_cards(&self) -> Vec { - self.inner.cached_cards().await - } - - /// Fetch from the remote endpoint when the services-owned cache is stale. - pub async fn fetch_if_stale(&self) { - self.inner.fetch_if_stale(Self::fetch_request().await).await; - } - - async fn fetch_request() -> Option { - let endpoint_url = Self::remote_url().await?; - if endpoint_url.is_empty() { - return None; - } - - Some(AnnouncementRemoteFetchRequest { - endpoint_url, - app_version: env!("CARGO_PKG_VERSION").to_string(), - locale: Self::current_locale().await, - platform: "desktop".to_string(), - }) - } - - async fn remote_url() -> Option { - use crate::service::config::get_global_config_service; - match get_global_config_service().await { - Ok(svc) => svc - .get_config::(Some("announcements.remote_url")) - .await - .ok(), - Err(_) => None, - } - } - - async fn current_locale() -> String { - use crate::service::config::get_global_config_service; - let Ok(service) = get_global_config_service().await else { - return "en-US".to_string(); - }; - service - .get_config::(Some("general.language")) - .await - .unwrap_or_else(|_| "en-US".to_string()) - } -} diff --git a/src/crates/assembly/core/src/service/announcement/scheduler.rs b/src/crates/assembly/core/src/service/announcement/scheduler.rs deleted file mode 100644 index c1df181f53..0000000000 --- a/src/crates/assembly/core/src/service/announcement/scheduler.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Announcement scheduling engine. -//! -//! On every application start, `AnnouncementScheduler::run` is called once. -//! It updates the persistent state, merges all card sources and returns the -//! ordered list of cards that should be presented in this session. - -use super::registry::local_cards; -use super::remote::RemoteFetcher; -use super::state_store::AnnouncementStateStore; -use super::tips_pool::builtin_tips; -use super::types::{AnnouncementCard, AnnouncementState, TriggerCondition}; -use crate::infrastructure::app_paths::PathManager; -use crate::util::errors::OpenBitFunResult; -use log::{debug, info}; -use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; - -/// Shared handle returned to `AppState`. -pub type AnnouncementSchedulerRef = Arc; - -pub struct AnnouncementScheduler { - store: AnnouncementStateStore, - remote_fetcher: RemoteFetcher, - state: RwLock, - save_lock: Mutex<()>, - current_version: String, -} - -impl AnnouncementScheduler { - /// Create a new scheduler and load persisted state from disk. - pub async fn new(path_manager: &Arc) -> OpenBitFunResult { - let store = AnnouncementStateStore::new(path_manager); - let remote_fetcher = RemoteFetcher::new(path_manager); - let state = store.load().await?; - let current_version = env!("CARGO_PKG_VERSION").to_string(); - Ok(Self { - store, - remote_fetcher, - state: RwLock::new(state), - save_lock: Mutex::new(()), - current_version, - }) - } - - /// Run scheduling: update version / open-count, collect and filter cards. - /// - /// `locale` is used to load the correct language variant of announcement - /// content (e.g. `"zh-CN"` or `"en-US"`). Falls back to `en-US` when the - /// requested locale has no files. - /// - /// Should be called once during application startup (non-blocking – awaited - /// inside the Tauri `setup` callback or from `announcement_api`). - pub async fn run(&self, locale: &str) -> OpenBitFunResult> { - let (open_count, is_version_first_open) = { - let mut state = self.state.write().await; - let is_version_first_open = state.last_seen_version != self.current_version; - - if is_version_first_open { - info!( - "Announcement scheduler: version upgrade detected ({} → {}); resetting dismissed_ids", - state.last_seen_version, self.current_version - ); - state.last_seen_version = self.current_version.clone(); - // On version upgrade the user should see version-specific cards again - // even if they were dismissed in a previous sub-release. - state.dismissed_ids.clear(); - } - - state.app_open_count += 1; - (state.app_open_count, is_version_first_open) - }; - self.save_current_state().await?; - - // Kick off remote fetch in the background (does not block card delivery). - let remote_fetcher = self.remote_fetcher.clone(); - tokio::spawn(async move { - remote_fetcher.fetch_if_stale().await; - }); - - // Merge all card sources. - let mut all_cards: Vec = Vec::new(); - all_cards.extend(local_cards(locale)); - all_cards.extend(builtin_tips(locale)); - all_cards.extend(self.remote_fetcher.cached_cards().await); - - debug!( - "Announcement scheduler: {} total cards before filtering", - all_cards.len() - ); - - let state = self.state.read().await; - let now = chrono::Utc::now().timestamp(); - - let mut eligible: Vec = all_cards - .into_iter() - .filter(|card| self.is_eligible(card, &state, open_count, is_version_first_open, now)) - .collect(); - - // Highest priority first; stable sort preserves source order for equal priorities. - eligible.sort_by_key(|card| std::cmp::Reverse(card.priority)); - - debug!( - "Announcement scheduler: {} cards eligible for display", - eligible.len() - ); - - Ok(eligible) - } - - /// Immediately mark a card as eligible for manual triggering (bypasses normal filters). - /// - /// `locale` is forwarded to the content loader for language selection. - pub async fn trigger_card(&self, id: &str, locale: &str) -> Option { - let all: Vec = local_cards(locale) - .into_iter() - .chain(builtin_tips(locale)) - .chain(self.remote_fetcher.cached_cards().await) - .collect(); - all.into_iter().find(|c| c.id == id) - } - - /// Record that the user has seen (opened the modal for) a card. - pub async fn mark_seen(&self, id: &str) -> OpenBitFunResult<()> { - { - let mut state = self.state.write().await; - state.seen_ids.insert(id.to_string()); - } - self.save_current_state().await - } - - /// Dismiss a card for the current version cycle. - pub async fn dismiss(&self, id: &str) -> OpenBitFunResult<()> { - { - let mut state = self.state.write().await; - state.dismissed_ids.insert(id.to_string()); - } - self.save_current_state().await - } - - /// Permanently suppress a card. - pub async fn never_show(&self, id: &str) -> OpenBitFunResult<()> { - { - let mut state = self.state.write().await; - state.never_show_ids.insert(id.to_string()); - } - self.save_current_state().await - } - - // ────────────────────────────────────────────────────────────────────── - // Private helpers - // ────────────────────────────────────────────────────────────────────── - - async fn save_current_state(&self) -> OpenBitFunResult<()> { - let _save_guard = self.save_lock.lock().await; - let state_snapshot = self.state.read().await.clone(); - self.store.save(&state_snapshot).await - } - - fn is_eligible( - &self, - card: &AnnouncementCard, - state: &AnnouncementState, - open_count: u64, - is_version_first_open: bool, - now: i64, - ) -> bool { - is_card_eligible( - card, - state, - &self.current_version, - open_count, - is_version_first_open, - now, - ) - } -} - -fn is_card_eligible( - card: &AnnouncementCard, - state: &AnnouncementState, - current_version: &str, - open_count: u64, - is_version_first_open: bool, - now: i64, -) -> bool { - // Never-show list takes absolute precedence. - if state.never_show_ids.contains(&card.id) { - return false; - } - - // Dismissed within the current version cycle. - if state.dismissed_ids.contains(&card.id) { - return false; - } - - // Expired remote card. - if let Some(expires) = card.expires_at { - if now >= expires { - return false; - } - } - - // Version restriction. - if let Some(required_version) = &card.app_version { - if required_version != current_version { - return false; - } - } - - // A card is only complete after the presentation surface reports that it - // mounted. `Always` deliberately stays eligible across failed starts until - // that durable acknowledgement exists. - if card.trigger.once_per_version && state.seen_ids.contains(&card.id) { - return false; - } - - match &card.trigger.condition { - TriggerCondition::VersionFirstOpen => is_version_first_open, - TriggerCondition::AppNthOpen { n } => open_count == *n, - TriggerCondition::Always => true, - TriggerCondition::Manual => false, - TriggerCondition::FeatureUsed { .. } => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn release_letter() -> AnnouncementCard { - local_cards("zh-CN") - .into_iter() - .find(|card| card.id == "release_letter_1_0_0") - .expect("embedded release letter") - } - - #[test] - fn release_letter_waits_for_a_durable_presentation_acknowledgement() { - let card = release_letter(); - let state = AnnouncementState::default(); - - assert!(is_card_eligible(&card, &state, "1.0.0", 1, true, 0)); - - let state_after_scheduler_run = AnnouncementState { - last_seen_version: "1.0.0".to_string(), - app_open_count: 1, - ..Default::default() - }; - assert!(is_card_eligible( - &card, - &state_after_scheduler_run, - "1.0.0", - 2, - false, - 0, - )); - - let mut presented_state = state_after_scheduler_run; - presented_state.seen_ids.insert(card.id.clone()); - assert!(!is_card_eligible( - &card, - &presented_state, - "1.0.0", - 3, - false, - 0, - )); - - assert!(!is_card_eligible( - &card, - &AnnouncementState::default(), - "1.0.1", - 1, - true, - 0, - )); - } -} diff --git a/src/crates/assembly/core/src/service/announcement/state_store.rs b/src/crates/assembly/core/src/service/announcement/state_store.rs deleted file mode 100644 index 940bc3d7dd..0000000000 --- a/src/crates/assembly/core/src/service/announcement/state_store.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::types::AnnouncementState; -use crate::infrastructure::app_paths::PathManager; -use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; -use std::sync::Arc; - -pub struct AnnouncementStateStore { - inner: openbitfun_services_integrations::announcement::AnnouncementStateStore, -} - -impl AnnouncementStateStore { - pub fn new(path_manager: &Arc) -> Self { - Self { - inner: openbitfun_services_integrations::announcement::AnnouncementStateStore::new( - path_manager.user_config_dir(), - ), - } - } - - /// Load state from disk. Returns a default state if the file does not exist. - pub async fn load(&self) -> OpenBitFunResult { - self.inner.load().await.map_err(map_state_store_error) - } - - /// Persist state to disk. - pub async fn save(&self, state: &AnnouncementState) -> OpenBitFunResult<()> { - self.inner.save(state).await.map_err(map_state_store_error) - } -} - -fn map_state_store_error( - err: openbitfun_services_integrations::announcement::AnnouncementStateStoreError, -) -> OpenBitFunError { - if err.is_deserialization() { - OpenBitFunError::Deserialization(err.to_string()) - } else if err.is_serialization() { - OpenBitFunError::serialization(err.to_string()) - } else { - OpenBitFunError::io(err.to_string()) - } -} diff --git a/src/crates/assembly/core/src/service/announcement/tips_pool.rs b/src/crates/assembly/core/src/service/announcement/tips_pool.rs deleted file mode 100644 index 686e09ab1d..0000000000 --- a/src/crates/assembly/core/src/service/announcement/tips_pool.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Built-in tips pool. -//! -//! Tips are lightweight cards (no modal) that teach users about features they -//! may have missed. Content is loaded from Markdown files embedded at compile -//! time via `content_loader`. - -use super::content_loader; -use super::types::AnnouncementCard; - -/// Returns the full list of built-in tip cards for the given locale. -/// -/// Content is sourced from `content/tips/{locale}/*.md` files. -/// Falls back to `en-US` if the requested locale has no tip files. -pub fn builtin_tips(locale: &str) -> Vec { - content_loader::load_tips(locale) -} diff --git a/src/crates/assembly/core/src/service/announcement/types.rs b/src/crates/assembly/core/src/service/announcement/types.rs deleted file mode 100644 index 253c965bc1..0000000000 --- a/src/crates/assembly/core/src/service/announcement/types.rs +++ /dev/null @@ -1 +0,0 @@ -pub use openbitfun_services_integrations::announcement::*; diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index e9de5b73a6..a3a18736e3 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -4,8 +4,6 @@ //! isolated. High-coupling runtime services stay here until their port //! contracts and equivalence tests are explicit. -#[cfg(feature = "announcement")] -pub mod announcement; // Announcement / feature-demo / tips system #[cfg(feature = "workspace-runtime")] pub(crate) mod bootstrap; // Workspace persona bootstrap helpers #[cfg(feature = "canvas-runtime")] @@ -66,8 +64,6 @@ pub mod worktree; // Managed Git worktree lifecycle and session bindings pub use terminal_core as terminal; // Re-export main components. -#[cfg(feature = "announcement")] -pub use announcement::{AnnouncementCard, AnnouncementScheduler, AnnouncementSchedulerRef}; #[cfg(feature = "workspace-runtime")] pub use bootstrap::reset_workspace_persona_files_to_default; #[cfg(feature = "canvas-runtime")] diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 276a0472bd..e41a30e812 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -708,7 +708,8 @@ pub struct NotificationConfig { /// Whether to show a toast notification when an approval request arrives while the window is not focused. #[serde(default = "default_true")] pub permission_request_notify: bool, - /// Whether to show built-in tip cards on startup (can be disabled by the user). + /// Retired startup-tip preference, retained only for configuration round-trip compatibility. + /// This field has no runtime effect. #[serde(default = "default_true")] pub enable_startup_tips: bool, } @@ -2521,6 +2522,25 @@ mod tests { assert!(config.permission_request_notify); } + #[test] + fn retired_startup_tip_preference_round_trips_legacy_notification_config() { + let legacy = serde_json::json!({ + "enabled": true, + "position": "bottom-left", + "duration": 4000, + "dialog_completion_notify": false, + "permission_request_notify": true, + "enable_startup_tips": false + }); + let config: NotificationConfig = serde_json::from_value(legacy.clone()) + .expect("legacy notification preferences should remain readable"); + + assert!(!config.enable_startup_tips); + assert!(!config.dialog_completion_notify); + assert!(config.permission_request_notify); + assert_eq!(serde_json::to_value(config).unwrap(), legacy); + } + #[test] fn agent_profile_defaults_keep_all_collections_empty() { let config = AgentProfileConfig::default(); diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 954e70afd1..2c40dcd46a 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", - "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", + "digest": "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b", + "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 325, + "documentedItems": 324, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 214, + "interactive": 213, "unsupported": 0 } }, @@ -10836,8 +10836,7 @@ "launch-at-login", "prevent-sleep", "close-behavior", - "notifications", - "announcements" + "notifications" ], "kind": "query", "risk": "read", @@ -11155,50 +11154,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:option:startup-tips", - "capabilityId": "setting.application.general", - "itemIds": [ - "notifications" - ], - "kind": "option", - "risk": "write", - "executionHost": "productHost", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": true - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": true - } - }, - "inputSchema": { - "type": "boolean" - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "valueSource": { - "kind": "static" - }, - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.general:open:manual-update", "capabilityId": "setting.application.general", @@ -11245,52 +11200,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:open:announcements", - "capabilityId": "setting.application.general", - "itemIds": [ - "announcements" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.appearance:query", "capabilityId": "setting.application.appearance", @@ -25120,12 +25029,12 @@ "highlightsZh": [ "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知" + "任务完成与权限请求通知" ], "highlightsEn": [ "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications" + "Completion and permission notifications" ], "items": [ { @@ -25197,8 +25106,8 @@ }, { "id": "notifications", - "titleZh": "分别控制任务完成、权限请求和启动提示通知", - "titleEn": "Control completion, permission-request, and startup-tip notifications independently", + "titleZh": "分别控制任务完成和权限请求通知", + "titleEn": "Control completion and permission-request notifications independently", "control": { "kind": "direct", "operations": [], @@ -25208,23 +25117,9 @@ }, { "id": "permission-notification" - }, - { - "id": "startup-tips" } ] } - }, - { - "id": "announcements", - "titleZh": "查看、标记已读、忽略或永久隐藏产品公告与提示", - "titleEn": "Review, mark seen, dismiss, or permanently hide product announcements and tips", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看、标记已读、忽略或永久隐藏产品公告与提示”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Review, mark seen, dismiss, or permanently hide product announcements and tips” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } } ], "stepsZh": [ @@ -25343,20 +25238,6 @@ "kind": "config", "path": "app.notifications.permission_request_notify" } - }, - { - "id": "startup-tips", - "titleZh": "启动提示", - "titleEn": "Startup tips", - "descriptionZh": "启动后显示功能提示。", - "descriptionEn": "Show product tips after startup.", - "valueSchema": { - "type": "boolean" - }, - "handler": { - "kind": "config", - "path": "app.notifications.enable_startup_tips" - } } ], "searchTerms": [ @@ -25378,10 +25259,10 @@ "prevent sleep", "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知", + "任务完成与权限请求通知", "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications", + "Completion and permission notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", @@ -25392,10 +25273,8 @@ "Choose whether OpenBitFun prevents the computer from sleeping while it runs", "选择关闭主窗口时退出、最小化到托盘或每次询问", "Choose whether closing the main window quits, minimizes to tray, or asks each time", - "分别控制任务完成、权限请求和启动提示通知", - "Control completion, permission-request, and startup-tip notifications independently", - "查看、标记已读、忽略或永久隐藏产品公告与提示", - "Review, mark seen, dismiss, or permanently hide product announcements and tips", + "分别控制任务完成和权限请求通知", + "Control completion and permission-request notifications independently", "关闭自动更新", "以后关闭窗口时最小化到托盘", "打开常规设置", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index e0211eedd0..73d41c5398 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:7a6e4c114b970186", + "digest": "fnv1a64:fd9e646a3f645e19", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -1492,18 +1492,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "dismiss_announcement", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "dispatch_answer", "surface": "tauri_command", @@ -2251,18 +2239,6 @@ "kind": "handled" } }, - { - "id": "get_announcement_tips", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "get_app_state", "surface": "tauri_command", @@ -2874,18 +2850,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "get_pending_announcements", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "get_pending_update", "surface": "tauri_command", @@ -4321,18 +4285,6 @@ "kind": "handled" } }, - { - "id": "mark_announcement_seen", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "mark_openbitfun_control_surface_ready", "surface": "tauri_command", @@ -4933,18 +4885,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "never_show_announcement", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "notify_cron_host_ready", "surface": "tauri_command", @@ -7815,18 +7755,6 @@ "kind": "handled" } }, - { - "id": "trigger_announcement", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "unarchive_session", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index 8b9da366b7..44113baf9e 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -353,12 +353,6 @@ pub fn owner_definitions() -> Vec { boolean(), config("app.notifications.permission_request_notify"), ), - option( - "setting.application.general", - "startup-tips", - boolean(), - config("app.notifications.enable_startup_tips"), - ), option( "setting.application.appearance", "theme", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 026a4582dd..be73b38a86 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -204,7 +204,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("delete_session", Unaudited, Proxied, HANDLED), op("delete_skill", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("delete_subagent", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("dismiss_announcement", Agnostic, ControllerLocal, REFUSED), op("dispatch_answer", Agnostic, ControllerLocal, REFUSED), op("dispatch_append", Agnostic, ControllerLocal, REFUSED), op("dispatch_cancel", Agnostic, ControllerLocal, REFUSED), @@ -269,7 +268,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_ai_model_catalog", Agnostic, Proxied, HANDLED), op("get_all_modified_files", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_all_tools_info", Unaudited, Proxied, HANDLED), - op("get_announcement_tips", Agnostic, ControllerLocal, REFUSED), op("get_app_state", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_app_version", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_assistant_workspaces", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), @@ -326,7 +324,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_opened_workspaces", Agnostic, Proxied, HANDLED), op("get_operation_diff", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("get_operation_summary", Routed, Proxied, CLI_NOT_IMPLEMENTED), - op("get_pending_announcements", Agnostic, ControllerLocal, REFUSED), op("get_pending_update", Agnostic, ControllerLocal, REFUSED), op("get_prevent_sleep_enabled", LocalOnly, ControllerLocal, REFUSED), op("get_primary_assistant_workspace", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), @@ -449,7 +446,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("local_file_download", Agnostic, ControllerLocal, REFUSED), op("logout_subscription_account", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("manage_dialog_queue", Routed, Proxied, HANDLED), - op("mark_announcement_seen", Agnostic, ControllerLocal, REFUSED), op("mark_openbitfun_control_surface_ready", Agnostic, ControllerLocal, REFUSED), op("mark_openbitfun_control_surface_unready", Agnostic, ControllerLocal, REFUSED), op("market_image_load", Agnostic, ControllerLocal, REFUSED), @@ -500,7 +496,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("miniapp_worker_stop", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("minimize_to_tray", LocalOnly, ControllerLocal, REFUSED), op("mutate_external_hook_import_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), - op("never_show_announcement", Agnostic, ControllerLocal, REFUSED), op("notify_cron_host_ready", Unaudited, Proxied, SOFT_EMPTY_NO_CRON_HOST), op("open_html_file_in_browser", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("open_remote_workspace", Routed, Proxied, HANDLED), @@ -745,7 +740,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("test_ai_connection", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("toggle_main_window_fullscreen", LocalOnly, ControllerLocal, REFUSED), op("touch_session_activity", Unaudited, Proxied, HANDLED), - op("trigger_announcement", Agnostic, ControllerLocal, REFUSED), op("unarchive_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("update_active_turn_permission_mode", Routed, Proxied, HANDLED), op("update_app_status", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 70ae078199..773a5941be 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -113,8 +113,6 @@ slices that are outside pure product logic but still platform-neutral. sandbox or a CPU/memory/filesystem/network resource limit; surfaces must keep those residual risks explicit. Unix descendants that deliberately create a new session/process group are outside the managed boundary. -- Announcement remote fetch/cache lives here; product assembly supplies config - values such as endpoint, locale, version, platform, and cache path. - DeepResearch report IO here owns report/citation sidecar filesystem work; provider-neutral citation numbering stays in `openbitfun-agent-runtime`. The IO path must use the injected `WorkspaceFileSystem` for both local and remote diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 40cc5d9b02..e0e197ff18 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -98,7 +98,6 @@ default = [] # feature group (e.g. `git`) only derive `TS` when that group is also enabled; # app-server's `ts` feature enables `product-full` which pulls `git`. ts = ["dep:ts-rs"] -announcement = ["openbitfun-services-core/json-io", "openbitfun-services-core/tls-provider", "reqwest", "reqwest/json", "reqwest/rustls-no-provider", "tokio/fs", "tokio/sync"] models-dev = [ "openbitfun-services-core/tls-provider", "reqwest", @@ -479,7 +478,6 @@ web-tools = [ "thiserror", ] product-full = [ - "announcement", "models-dev", "browser-control", "canvas-runtime", @@ -511,11 +509,6 @@ name = "script_tool_runtime" path = "tests/script_tool_runtime.rs" required-features = ["script-tool-runtime"] -[[test]] -name = "announcement_contracts" -path = "tests/announcement_contracts.rs" -required-features = ["announcement"] - [[test]] name = "file_watch_contracts" path = "tests/file_watch_contracts.rs" diff --git a/src/crates/services/services-integrations/src/announcement/mod.rs b/src/crates/services/services-integrations/src/announcement/mod.rs deleted file mode 100644 index 46ccbb4c66..0000000000 --- a/src/crates/services/services-integrations/src/announcement/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Announcement service data contracts. - -mod remote; -mod state_store; -mod types; - -pub use remote::{AnnouncementRemoteFetchRequest, RemoteAnnouncementFetcher}; -pub use state_store::{ - AnnouncementStateStore, AnnouncementStateStoreError, AnnouncementStateStoreResult, -}; -pub use types::*; diff --git a/src/crates/services/services-integrations/src/announcement/remote.rs b/src/crates/services/services-integrations/src/announcement/remote.rs deleted file mode 100644 index 7c4183fbc3..0000000000 --- a/src/crates/services/services-integrations/src/announcement/remote.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Remote announcement fetcher. -//! -//! Product assembly supplies configuration values. This module owns remote HTTP -//! fetch, cache TTL, disk cache hydration, and in-memory cache state. - -use crate::announcement::AnnouncementCard; -use log::{debug, info, warn}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::fs; -use tokio::sync::RwLock; - -const CACHE_TTL_SECS: i64 = 86_400; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AnnouncementRemoteFetchRequest { - pub endpoint_url: String, - pub app_version: String, - pub locale: String, - pub platform: String, -} - -impl AnnouncementRemoteFetchRequest { - pub fn request_url(&self) -> String { - format!( - "{}?app_version={}&locale={}&platform={}", - self.endpoint_url, self.app_version, self.locale, self.platform - ) - } -} - -#[derive(Clone)] -pub struct RemoteAnnouncementFetcher { - cache_file: PathBuf, - cached: Arc>>, -} - -impl RemoteAnnouncementFetcher { - pub fn new(cache_file: impl Into) -> Self { - Self { - cache_file: cache_file.into(), - cached: Arc::new(RwLock::new(Vec::new())), - } - } - - pub async fn cached_cards(&self) -> Vec { - let cards = self.cached.read().await.clone(); - if !cards.is_empty() { - return cards; - } - self.load_disk_cache().await - } - - pub async fn fetch_if_stale(&self, request: Option) { - let Some(request) = request.filter(|request| !request.endpoint_url.is_empty()) else { - debug!("No remote announcement URL configured; skipping fetch"); - return; - }; - - if let Ok(meta) = fs::metadata(&self.cache_file).await { - if let Ok(modified) = meta.modified() { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default() - .as_secs() as i64; - if age < CACHE_TTL_SECS { - debug!("Remote announcement cache is fresh ({} s old)", age); - return; - } - } - } - - let request_url = request.request_url(); - info!( - "Fetching remote announcements from {}", - request.endpoint_url - ); - - match crate::reqwest_client().get(&request_url).send().await { - Ok(resp) if resp.status().is_success() => { - match resp.json::>().await { - Ok(cards) => { - info!("Fetched {} remote announcement cards", cards.len()); - if let Ok(json) = serde_json::to_string_pretty(&cards) { - let _ = fs::write(&self.cache_file, json).await; - } - *self.cached.write().await = cards; - } - Err(error) => warn!("Failed to parse remote announcements: {}", error), - } - } - Ok(resp) => warn!( - "Remote announcement endpoint returned HTTP {}", - resp.status() - ), - Err(error) => warn!("Failed to fetch remote announcements: {}", error), - } - } - - async fn load_disk_cache(&self) -> Vec { - match fs::read_to_string(&self.cache_file).await { - Ok(content) => { - let cards = - serde_json::from_str::>(&content).unwrap_or_default(); - let mut lock = self.cached.write().await; - *lock = cards.clone(); - cards - } - Err(_) => Vec::new(), - } - } -} diff --git a/src/crates/services/services-integrations/src/announcement/state_store.rs b/src/crates/services/services-integrations/src/announcement/state_store.rs deleted file mode 100644 index 0ac3bb73aa..0000000000 --- a/src/crates/services/services-integrations/src/announcement/state_store.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Persistent state store for the announcement system. -//! -//! Reads and writes `announcement-state.json` under the supplied user config -//! directory. This stays independent from core path management. - -use std::fmt; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU8, Ordering}; - -use log::{debug, warn}; -use openbitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; - -use super::types::AnnouncementState; - -const SAVE_TO_PRIMARY: u8 = 0; -const SAVE_TO_RECOVERY: u8 = 1; -const SAVE_DISABLED: u8 = 2; - -#[derive(Debug)] -pub enum AnnouncementStateStoreError { - Json(JsonFileStoreError), - PersistenceDisabled { state_file: PathBuf }, -} - -impl fmt::Display for AnnouncementStateStoreError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Json(err) => write!(f, "{err}"), - Self::PersistenceDisabled { state_file } => write!( - f, - "Announcement state persistence is disabled because both the primary and recovery files are unreadable: {}", - state_file.display() - ), - } - } -} - -impl std::error::Error for AnnouncementStateStoreError {} - -impl From for AnnouncementStateStoreError { - fn from(value: JsonFileStoreError) -> Self { - Self::Json(value) - } -} - -impl AnnouncementStateStoreError { - pub fn is_deserialization(&self) -> bool { - matches!(self, Self::Json(error) if error.is_deserialization()) - } - - pub fn is_serialization(&self) -> bool { - matches!(self, Self::Json(error) if error.is_serialization()) - } -} - -pub type AnnouncementStateStoreResult = Result; - -pub struct AnnouncementStateStore { - state_file: PathBuf, - recovery_file: PathBuf, - json_store: JsonFileStore, - save_target: AtomicU8, -} - -impl AnnouncementStateStore { - pub fn new(config_dir: impl AsRef) -> Self { - Self::from_state_file(config_dir.as_ref().join("announcement-state.json")) - } - - pub fn from_state_file(state_file: impl Into) -> Self { - let state_file = state_file.into(); - let recovery_file = recovery_file_for(&state_file); - Self { - state_file, - recovery_file, - json_store: JsonFileStore, - save_target: AtomicU8::new(SAVE_TO_PRIMARY), - } - } - - pub fn state_file(&self) -> &Path { - &self.state_file - } - - /// Load state from disk. Returns a default state if the file does not exist - /// or cannot be parsed, preserving the legacy best-effort contract. - /// - /// An unreadable primary file is never overwritten. Subsequent saves use a - /// recovery sidecar so one damaged record cannot either erase the original - /// bytes or make a first-run announcement repeat forever. - pub async fn load(&self) -> AnnouncementStateStoreResult { - match self - .json_store - .read_optional::(&self.state_file) - .await - { - Ok(Some(state)) => { - self.save_target.store(SAVE_TO_PRIMARY, Ordering::Release); - debug!("Loaded announcement state from {:?}", self.state_file); - Ok(state) - } - Ok(None) => self.load_recovery_after_missing_primary().await, - Err(error) => { - warn!( - "Failed to read announcement state file {}; preserving it and using recovery state: {}", - self.state_file.display(), - error - ); - self.save_target.store(SAVE_TO_RECOVERY, Ordering::Release); - match self - .json_store - .read_optional::(&self.recovery_file) - .await - { - Ok(Some(state)) => { - debug!( - "Loaded announcement recovery state from {:?}", - self.recovery_file - ); - Ok(state) - } - Ok(None) => Ok(AnnouncementState::default()), - Err(recovery_error) => { - self.save_target.store(SAVE_DISABLED, Ordering::Release); - warn!( - "Failed to read announcement recovery state {}; persistence is disabled for this process: {}", - self.recovery_file.display(), - recovery_error - ); - Ok(AnnouncementState::default()) - } - } - } - } - } - - /// Persist state using a same-directory atomic replacement. - pub async fn save(&self, state: &AnnouncementState) -> AnnouncementStateStoreResult<()> { - let target = match self.save_target.load(Ordering::Acquire) { - SAVE_TO_PRIMARY => &self.state_file, - SAVE_TO_RECOVERY => &self.recovery_file, - _ => { - return Err(AnnouncementStateStoreError::PersistenceDisabled { - state_file: self.state_file.clone(), - }); - } - }; - - let _cross_process_lock = self.json_store.acquire_cross_process_lock(target).await?; - self.json_store.write_atomic_strict(target, state).await?; - debug!("Saved announcement state to {:?}", target); - Ok(()) - } - - async fn load_recovery_after_missing_primary( - &self, - ) -> AnnouncementStateStoreResult { - self.save_target.store(SAVE_TO_PRIMARY, Ordering::Release); - match self - .json_store - .read_optional::(&self.recovery_file) - .await - { - Ok(Some(state)) => { - debug!( - "Primary announcement state is missing; restoring from {:?}", - self.recovery_file - ); - Ok(state) - } - Ok(None) => { - debug!("Announcement state file not found, using default"); - Ok(AnnouncementState::default()) - } - Err(error) => { - warn!( - "Failed to read orphaned announcement recovery state {}; preserving it and using default: {}", - self.recovery_file.display(), - error - ); - Ok(AnnouncementState::default()) - } - } - } -} - -fn recovery_file_for(state_file: &Path) -> PathBuf { - let stem = state_file - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("announcement-state"); - state_file.with_file_name(format!("{stem}.recovery.json")) -} diff --git a/src/crates/services/services-integrations/src/announcement/types.rs b/src/crates/services/services-integrations/src/announcement/types.rs deleted file mode 100644 index ef8884dbb5..0000000000 --- a/src/crates/services/services-integrations/src/announcement/types.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Announcement system types. -//! -//! Defines all data structures for the announcement / feature-demo / tips mechanism. - -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -/// Categories of announcement cards. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CardType { - /// New version feature showcase. - Feature, - /// Operational news or blog post. - News, - /// Lightweight usage tip (toast only, no modal). - Tip, - /// Important system announcement (shown as modal without prior toast). - Announcement, -} - -/// Origin of an announcement card. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CardSource { - /// Statically registered in the local binary. - Local, - /// Downloaded from a remote endpoint. - Remote, - /// Built-in tips pool. - BuiltinTip, -} - -/// Conditions that must be met before a card is shown. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum TriggerCondition { - /// First launch after a version upgrade. - VersionFirstOpen, - /// The N-th time the application has been opened (1-indexed). - AppNthOpen { n: u64 }, - /// A named application feature was used (supplied programmatically). - FeatureUsed { feature: String }, - /// Must be triggered manually via `trigger_announcement`. - Manual, - /// Always eligible (used for announcements that should appear on every start until dismissed). - Always, -} - -/// When and how a card should be presented. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TriggerRule { - pub condition: TriggerCondition, - /// Milliseconds to wait after application start before displaying. - #[serde(default)] - pub delay_ms: u64, - /// When true, a card is only shown once per application version. - #[serde(default = "default_true")] - pub once_per_version: bool, -} - -fn default_true() -> bool { - true -} - -impl Default for TriggerRule { - fn default() -> Self { - Self { - condition: TriggerCondition::VersionFirstOpen, - delay_ms: 2000, - once_per_version: true, - } - } -} - -/// Configuration for the bottom-left toast entry point. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToastConfig { - /// Icon identifier or emoji string (rendered by the frontend). - pub icon: String, - /// Toast title (i18n key or literal text). - pub title: String, - /// Short description shown below the title (i18n key or literal text). - pub description: String, - /// Label for the primary action button (i18n key or literal text). - #[serde(default)] - pub action_label: String, - /// Whether the user can close the toast without acting. - #[serde(default = "default_true")] - pub dismissible: bool, - /// Auto-dismiss after this many milliseconds; `None` means no auto-dismiss. - #[serde(default)] - pub auto_dismiss_ms: Option, -} - -/// Preferred modal size. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum ModalSize { - Sm, - Md, - #[default] - Lg, - Xl, -} - -/// Visual treatment used by a modal. -/// -/// The standard presentation remains the wire-compatible default. Bespoke, -/// release-bound presentations must opt in explicitly so ordinary and remote -/// announcements never inherit product-specific UI accidentally. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum ModalPresentation { - #[default] - Standard, - ReleaseLetter, -} - -impl ModalPresentation { - fn is_standard(&self) -> bool { - matches!(self, Self::Standard) - } -} - -/// What happens when the user finishes or closes the modal. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum CompletionAction { - /// Only dismiss for this session; may reappear next launch if conditions match. - #[default] - Dismiss, - /// Permanently suppress via `never_show_ids`. - NeverShowAgain, -} - -/// Layout template for a single modal page. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum PageLayout { - TextOnly, - MediaLeft, - MediaRight, - #[default] - MediaTop, - FullscreenMedia, -} - -/// Media asset type. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MediaType { - Lottie, - Video, - Image, - Gif, -} - -/// A media asset attached to a modal page. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MediaConfig { - pub media_type: MediaType, - /// Relative path under `public/announcements/` or an HTTPS URL. - pub src: String, -} - -/// A single page inside a feature modal. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModalPage { - #[serde(default)] - pub layout: PageLayout, - /// Page title (i18n key or literal text). - pub title: String, - /// Body copy in Markdown (i18n key or literal text). - pub body: String, - #[serde(default)] - pub media: Option, -} - -/// Full configuration for the centre modal overlay. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModalConfig { - #[serde(default)] - pub size: ModalSize, - #[serde(default, skip_serializing_if = "ModalPresentation::is_standard")] - pub presentation: ModalPresentation, - /// Allow the user to close the modal with the × button. - #[serde(default = "default_true")] - pub closable: bool, - pub pages: Vec, - #[serde(default)] - pub completion_action: CompletionAction, -} - -/// A single announcement / feature-demo card. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnnouncementCard { - /// Globally unique identifier, e.g. `feature_v1_3_0_miniapp`. - pub id: String, - pub card_type: CardType, - pub source: CardSource, - /// Application version this card is associated with. `None` = any version. - #[serde(default)] - pub app_version: Option, - /// Higher priority cards are shown first. - #[serde(default)] - pub priority: i32, - pub trigger: TriggerRule, - pub toast: ToastConfig, - /// If `None`, no modal is opened when the user clicks the toast action. - #[serde(default)] - pub modal: Option, - /// Unix timestamp (seconds) after which the card is ignored. Remote cards only. - #[serde(default)] - pub expires_at: Option, -} - -/// Persisted state for the announcement system. -/// -/// Stored at `~/.config/openbitfun/config/announcement-state.json`. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AnnouncementState { - /// Version string recorded when the state was last saved. - #[serde(default)] - pub last_seen_version: String, - /// How many times the application has been opened. - #[serde(default)] - pub app_open_count: u64, - /// IDs of cards the user has seen (action button clicked or modal opened). - #[serde(default)] - pub seen_ids: HashSet, - /// IDs dismissed for the current version cycle; reset on version upgrade. - #[serde(default)] - pub dismissed_ids: HashSet, - /// IDs the user has permanently suppressed. - #[serde(default)] - pub never_show_ids: HashSet, - /// Unix timestamp (seconds) of the last successful remote fetch. - #[serde(default)] - pub last_remote_fetch_at: Option, -} diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 46c059593d..6be12173d2 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -22,7 +22,6 @@ pub(crate) fn reqwest_client_builder() -> reqwest::ClientBuilder { } #[cfg(any( - feature = "announcement", feature = "browser-control", feature = "mcp", feature = "remote-connect", @@ -32,9 +31,6 @@ pub(crate) fn reqwest_client() -> reqwest::Client { reqwest::Client::new() } -#[cfg(feature = "announcement")] -pub mod announcement; - #[cfg(feature = "miniapp-market")] pub mod appearance_market; diff --git a/src/crates/services/services-integrations/tests/announcement_contracts.rs b/src/crates/services/services-integrations/tests/announcement_contracts.rs deleted file mode 100644 index 787de942a0..0000000000 --- a/src/crates/services/services-integrations/tests/announcement_contracts.rs +++ /dev/null @@ -1,283 +0,0 @@ -#![cfg(feature = "announcement")] - -use openbitfun_services_integrations::announcement::{ - AnnouncementCard, AnnouncementRemoteFetchRequest, AnnouncementState, AnnouncementStateStore, - CardSource, CardType, CompletionAction, ModalConfig, ModalPage, ModalPresentation, ModalSize, - PageLayout, RemoteAnnouncementFetcher, ToastConfig, TriggerCondition, TriggerRule, -}; - -#[test] -fn announcement_enum_defaults_preserve_variants_and_wire_values() { - assert!(matches!(ModalSize::default(), ModalSize::Lg)); - assert!(matches!( - ModalPresentation::default(), - ModalPresentation::Standard - )); - assert!(matches!( - CompletionAction::default(), - CompletionAction::Dismiss - )); - assert!(matches!(PageLayout::default(), PageLayout::MediaTop)); - assert_eq!( - serde_json::to_string(&ModalSize::default()).unwrap(), - "\"lg\"" - ); - assert_eq!( - serde_json::to_string(&CompletionAction::default()).unwrap(), - "\"dismiss\"" - ); - assert_eq!( - serde_json::to_string(&PageLayout::default()).unwrap(), - "\"media_top\"" - ); -} - -#[test] -fn announcement_card_deserialization_preserves_default_contract() { - let card: AnnouncementCard = serde_json::from_value(serde_json::json!({ - "id": "feature_v1", - "card_type": "feature", - "source": "local", - "trigger": { - "condition": { - "type": "app_nth_open", - "n": 3 - } - }, - "toast": { - "icon": "sparkles", - "title": "Feature", - "description": "Try it" - } - })) - .unwrap(); - - assert_eq!(card.id, "feature_v1"); - assert_eq!(card.card_type, CardType::Feature); - assert_eq!(card.source, CardSource::Local); - assert_eq!(card.priority, 0); - assert_eq!(card.app_version, None); - assert!(card.modal.is_none()); - assert_eq!(card.expires_at, None); - assert!(matches!( - card.trigger.condition, - TriggerCondition::AppNthOpen { n: 3 } - )); - assert_eq!(card.trigger.delay_ms, 0); - assert!(card.trigger.once_per_version); - assert_eq!(card.toast.action_label, ""); - assert!(card.toast.dismissible); - assert_eq!(card.toast.auto_dismiss_ms, None); -} - -#[test] -fn announcement_modal_serialization_preserves_snake_case_contract() { - let modal = ModalConfig { - size: ModalSize::Xl, - presentation: ModalPresentation::Standard, - closable: true, - pages: vec![ModalPage { - layout: PageLayout::FullscreenMedia, - title: "Showcase".to_string(), - body: "Details".to_string(), - media: None, - }], - completion_action: CompletionAction::NeverShowAgain, - }; - - assert_eq!( - serde_json::to_value(modal).unwrap(), - serde_json::json!({ - "size": "xl", - "closable": true, - "pages": [{ - "layout": "fullscreen_media", - "title": "Showcase", - "body": "Details", - "media": null - }], - "completion_action": "never_show_again" - }) - ); -} - -#[test] -fn release_letter_presentation_is_explicit_and_legacy_standard_is_omitted() { - let release = ModalConfig { - size: ModalSize::Xl, - presentation: ModalPresentation::ReleaseLetter, - closable: true, - pages: Vec::new(), - completion_action: CompletionAction::Dismiss, - }; - assert_eq!( - serde_json::to_value(release).unwrap()["presentation"], - "release_letter" - ); - - let legacy: ModalConfig = serde_json::from_value(serde_json::json!({ - "size": "lg", - "closable": true, - "pages": [], - "completion_action": "dismiss" - })) - .unwrap(); - assert_eq!(legacy.presentation, ModalPresentation::Standard); -} - -#[test] -fn announcement_state_and_trigger_defaults_preserve_runtime_assumptions() { - let trigger = TriggerRule::default(); - assert!(matches!( - trigger.condition, - TriggerCondition::VersionFirstOpen - )); - assert_eq!(trigger.delay_ms, 2000); - assert!(trigger.once_per_version); - - let state = AnnouncementState::default(); - assert_eq!(state.last_seen_version, ""); - assert_eq!(state.app_open_count, 0); - assert!(state.seen_ids.is_empty()); - assert!(state.dismissed_ids.is_empty()); - assert!(state.never_show_ids.is_empty()); - assert_eq!(state.last_remote_fetch_at, None); - - let toast = ToastConfig { - icon: "tip".to_string(), - title: "Tip".to_string(), - description: "Use shortcuts".to_string(), - action_label: String::new(), - dismissible: true, - auto_dismiss_ms: None, - }; - assert!(toast.dismissible); -} - -#[tokio::test] -async fn announcement_state_store_round_trips_state_and_defaults_missing_file() { - let root = std::env::temp_dir().join(format!( - "openbitfun-announcement-state-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let store = AnnouncementStateStore::new(&root); - - let missing = store.load().await.expect("load missing state"); - assert_eq!(missing.last_seen_version, ""); - assert_eq!(missing.app_open_count, 0); - assert!(missing.seen_ids.is_empty()); - assert!(missing.dismissed_ids.is_empty()); - assert!(missing.never_show_ids.is_empty()); - assert_eq!(missing.last_remote_fetch_at, None); - - let mut state = AnnouncementState { - app_open_count: 7, - ..Default::default() - }; - state.seen_ids.insert("feature-a".to_string()); - state.dismissed_ids.insert("tip-b".to_string()); - store.save(&state).await.expect("save state"); - - let loaded = store.load().await.expect("load saved state"); - assert_eq!(loaded.app_open_count, 7); - assert!(loaded.seen_ids.contains("feature-a")); - assert!(loaded.dismissed_ids.contains("tip-b")); - - let _ = std::fs::remove_dir_all(root); -} - -#[tokio::test] -async fn announcement_state_store_preserves_a_damaged_primary_and_uses_recovery_state() { - let root = tempfile::tempdir().unwrap(); - let state_file = root.path().join("announcement-state.json"); - let recovery_file = root.path().join("announcement-state.recovery.json"); - let damaged_bytes = b"{not-valid-json"; - tokio::fs::write(&state_file, damaged_bytes).await.unwrap(); - - let store = AnnouncementStateStore::new(root.path()); - let initial = store.load().await.expect("load damaged primary"); - assert_eq!(initial.app_open_count, 0); - - let mut recovered = AnnouncementState { - app_open_count: 2, - ..Default::default() - }; - recovered - .seen_ids - .insert("release_letter_1_0_0".to_string()); - store.save(&recovered).await.expect("save recovery state"); - - assert_eq!(tokio::fs::read(&state_file).await.unwrap(), damaged_bytes); - assert!(recovery_file.exists()); - - let reloaded = AnnouncementStateStore::new(root.path()) - .load() - .await - .expect("reload recovery state"); - assert_eq!(reloaded.app_open_count, 2); - assert!(reloaded.seen_ids.contains("release_letter_1_0_0")); -} - -#[test] -fn announcement_remote_fetch_request_preserves_legacy_query_shape() { - let request = AnnouncementRemoteFetchRequest { - endpoint_url: "https://announcements.example.com/cards".to_string(), - app_version: "0.2.15".to_string(), - locale: "zh-CN".to_string(), - platform: "desktop".to_string(), - }; - - assert_eq!( - request.request_url(), - "https://announcements.example.com/cards?app_version=0.2.15&locale=zh-CN&platform=desktop" - ); -} - -#[tokio::test] -async fn announcement_remote_fetcher_loads_disk_cache_before_network_fetch() { - let root = std::env::temp_dir().join(format!( - "openbitfun-announcement-remote-cache-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - std::fs::create_dir_all(&root).unwrap(); - let cache_file = root.join("announcement-remote-cache.json"); - std::fs::write( - &cache_file, - serde_json::to_string(&vec![AnnouncementCard { - id: "remote-tip".to_string(), - card_type: CardType::Tip, - source: CardSource::Remote, - app_version: None, - priority: 3, - trigger: TriggerRule::default(), - toast: ToastConfig { - icon: "tip".to_string(), - title: "Remote".to_string(), - description: "Cached".to_string(), - action_label: String::new(), - dismissible: true, - auto_dismiss_ms: None, - }, - modal: None, - expires_at: None, - }]) - .unwrap(), - ) - .unwrap(); - - let fetcher = RemoteAnnouncementFetcher::new(cache_file); - let cards = fetcher.cached_cards().await; - - assert_eq!(cards.len(), 1); - assert_eq!(cards[0].id, "remote-tip"); - assert_eq!(cards[0].source, CardSource::Remote); - let _ = std::fs::remove_dir_all(root); -} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 0006c7e673..de9bb8d26b 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -5255,12 +5255,12 @@ "highlightsZh": [ "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知" + "任务完成与权限请求通知" ], "highlightsEn": [ "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications" + "Completion and permission notifications" ], "items": [ { @@ -5356,8 +5356,8 @@ }, { "id": "notifications", - "titleZh": "分别控制任务完成、权限请求和启动提示通知", - "titleEn": "Control completion, permission-request, and startup-tip notifications independently", + "titleZh": "分别控制任务完成和权限请求通知", + "titleEn": "Control completion and permission-request notifications independently", "control": { "kind": "direct", "operations": [], @@ -5367,9 +5367,6 @@ }, { "id": "permission-notification" - }, - { - "id": "startup-tips" } ] }, @@ -5378,25 +5375,6 @@ "source:src/web-ui/src/locales/zh-CN/settings/application.json#notifications.permissionRequest.label", "command:send_system_notification" ] - }, - { - "id": "announcements", - "titleZh": "查看、标记已读、忽略或永久隐藏产品公告与提示", - "titleEn": "Review, mark seen, dismiss, or permanently hide product announcements and tips", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看、标记已读、忽略或永久隐藏产品公告与提示”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Review, mark seen, dismiss, or permanently hide product announcements and tips” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - }, - "evidence": [ - "command:get_pending_announcements", - "command:mark_announcement_seen", - "command:dismiss_announcement", - "command:never_show_announcement", - "command:get_announcement_tips", - "command:trigger_announcement" - ] } ], "stepsZh": [ @@ -5466,13 +5444,6 @@ "titleEn": "Permission request notifications", "descriptionZh": "智能体等待权限确认时发送系统通知。", "descriptionEn": "Send an OS notification when an agent waits for permission." - }, - { - "id": "startup-tips", - "titleZh": "启动提示", - "titleEn": "Startup tips", - "descriptionZh": "启动后显示功能提示。", - "descriptionEn": "Show product tips after startup." } ] }, @@ -9574,9 +9545,6 @@ "html_preview": { "capabilityId": "feature.files-editor" }, - "announcement": { - "capabilityId": "setting.application.general" - }, "tool": { "capabilityId": "feature.ai-assistant" }, diff --git a/src/web-ui/MOTION_AUDIT.md b/src/web-ui/MOTION_AUDIT.md index d5c2da50d2..5cead5e9a5 100644 --- a/src/web-ui/MOTION_AUDIT.md +++ b/src/web-ui/MOTION_AUDIT.md @@ -24,7 +24,7 @@ pnpm run motion:audit | --- | --- | --- | | 动效基础与组件原语 | 时长/曲线不统一;Button、Input、Card、Select 等大量 `transition: all`;hover 放大未限制精确指针;Tooltip 双重入场 | 统一更短的 motion token 与 decelerate 曲线;显式列出属性;hover 仅限 fine pointer;按下反馈约 `.97`;Tooltip 单一动效所有权 | | Dialog、Popover、Select、菜单 | 上层条件卸载使 Dialog 退出失效;自定义 overlay 只进不退;placement 翻转后方向错误;快速关闭/重开被 keyframe 抢占 | 统一使用 `RetainedMountBoundary`;Dialog 调用方、自定义 overlay、Select、模型/推理/会话树弹层均保留退出态与最后快照;按实际 placement 设置 origin;改为可中断 transition;退出期 `inert`/`aria-hidden`;键盘打开即时 | -| 通知与公告 | 通知删除和堆栈重排跳变;进度条动画 width;Feature modal 翻页 `display:none`;toast/弹窗退出计时不一致、动效过弹 | 通知退出 retention 与堆栈收拢;进度改 `scaleX`;公告弹窗/Toast 缩短并对齐计时;Feature modal 方向感知的 8px crossfade;退出禁交互并清理 timer | +| 通知 | 通知删除和堆栈重排跳变;进度条动画 width | 通知退出 retention 与堆栈收拢;进度改 `scaleX`;退出禁交互并清理 timer | | Flow Chat | 选择器/文件弹层无退出;权限、运行状态、BTW 操作条瞬移;滚到底部控件闪现;TokenUsage 改高度/宽度;Pixel Pet 在 reduced-motion 下仍强行动画 | 补齐退出与焦点交接;权限快照绑定 session;BTW 退出期保留布局;运行状态固定槽 crossfade;高频滚动控件仅短 opacity;TokenUsage 固定 hitbox + `scaleX`;无限动效均有静止降级 | | 列表、折叠与配置 | Context/Todo/权限规则删除后空间跳变;配置 disclosure 只有入口;Nav 主 section 被覆盖为无动画;Todo 编辑器关闭时内容/高度突变 | Context 行稳定退出;Todo 编辑器/日期详情冻结完整数据并同步收起布局;权限规则增删与排序用可取消 FLIP;配置 disclosure 使用 grid presence;Nav section 恢复 150ms 收拢 | | 拖拽与排序 | Tab 与 Workspace 拖拽时邻项不让位、drop 后瞬移;Workspace drop line 作为 flex child 改变布局 | 拖动本体保持 1:1;邻项 120ms transform 让位;drop 后 160ms FLIP;drop line 改绝对定位,不再挤动列表 | @@ -41,7 +41,6 @@ pnpm run motion:audit | 完成 | BranchSelect、Diff fullscreen、Peer directory picker | 自定义 overlay/surface 具备进出场、退出快照、焦点与异步竞态保护 | | 完成 | ContextMenu | placement origin、100ms 退出、退出期 inert;补齐 roving focus、方向键、Home/End、Enter/Space、Escape 与子菜单焦点返回 | | 完成 | Notification | Toast 退出 retention、焦点先移出再 inert、堆栈收拢;进度条由 width 改为 `scaleX` | -| 完成 | Announcement Toast、Feature Modal | 取消过度 bounce/ghost 位移;hover 降幅并 gating;翻页方向感知;JS/CSS 退出计时对齐 | | 完成 | ModelSelector、ReasoningPreset、SessionTree、输入区弹层 | 根据实际 placement 决定位移和 origin;退出保留、快速重开、兄弟菜单清理与焦点返回 | | 完成 | Permission、BTW、RuntimeStatus | 会话切换不残留旧权限卡;操作条退出时不塌布局;运行状态使用固定槽 crossfade | | 完成 | ScrollToBottom、ScrollToLatest、TokenUsage | 高频控件只做短 opacity;退出前焦点回到消息滚动区;进度固定 hitbox + `scaleX` | diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 096bb51a11..f3f9c3d4a4 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -9,7 +9,6 @@ import { NotificationContainer, notificationService } from '../shared/notificati import { ComputerUseControlCard } from './components/ComputerUseControlCard'; import { UpdateNotificationCard } from '@/infrastructure/update/UpdateNotificationCard'; import { NotificationCenter } from '../shared/notification-system/components/NotificationCenter'; -import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '@/infrastructure/confirm-dialog'; import { SessionUsageModal } from '../flow_chat/components/usage/SessionUsageModal'; import { createLogger } from '@/shared/utils/logger'; @@ -1006,9 +1005,6 @@ function App() { whichever session view is on screen. */} - {/* Announcement / feature-demo / tips system */} - - {/* AskUserQuestion waiting-state aria-live announcer. Mounted here (inside ToolbarModeProvider, outside LazyAppLayout) so it persists across both normal and Toolbar Mode. */} diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 6eacd2da54..3c5126dbfc 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d", - "ownerDigest": "2269c2432e5db44706b2bce44df3c60672141a47c36141aeb1257ea7fd2507f8", + "digest": "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b", + "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 325, + "documentedItems": 324, "controlCoverage": { "direct": 49, "delegated": 62, - "interactive": 214, + "interactive": 213, "unsupported": 0 } }, @@ -10836,8 +10836,7 @@ "launch-at-login", "prevent-sleep", "close-behavior", - "notifications", - "announcements" + "notifications" ], "kind": "query", "risk": "read", @@ -11155,50 +11154,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:option:startup-tips", - "capabilityId": "setting.application.general", - "itemIds": [ - "notifications" - ], - "kind": "option", - "risk": "write", - "executionHost": "productHost", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": true - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": true - } - }, - "inputSchema": { - "type": "boolean" - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "valueSource": { - "kind": "static" - }, - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.general:open:manual-update", "capabilityId": "setting.application.general", @@ -11245,52 +11200,6 @@ "pageId": "application.general" } }, - { - "id": "setting.application.general:open:announcements", - "capabilityId": "setting.application.general", - "itemIds": [ - "announcements" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "settings", - "pageId": "application.general" - } - }, { "id": "setting.application.appearance:query", "capabilityId": "setting.application.appearance", @@ -25079,12 +24988,12 @@ "highlightsZh": [ "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知" + "任务完成与权限请求通知" ], "highlightsEn": [ "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications" + "Completion and permission notifications" ], "items": [ { @@ -25156,8 +25065,8 @@ }, { "id": "notifications", - "titleZh": "分别控制任务完成、权限请求和启动提示通知", - "titleEn": "Control completion, permission-request, and startup-tip notifications independently", + "titleZh": "分别控制任务完成和权限请求通知", + "titleEn": "Control completion and permission-request notifications independently", "control": { "kind": "direct", "operations": [], @@ -25167,23 +25076,9 @@ }, { "id": "permission-notification" - }, - { - "id": "startup-tips" } ] } - }, - { - "id": "announcements", - "titleZh": "查看、标记已读、忽略或永久隐藏产品公告与提示", - "titleEn": "Review, mark seen, dismiss, or permanently hide product announcements and tips", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看、标记已读、忽略或永久隐藏产品公告与提示”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Review, mark seen, dismiss, or permanently hide product announcements and tips” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } } ], "stepsZh": [ @@ -25276,16 +25171,6 @@ "valueSchema": { "type": "boolean" } - }, - { - "id": "startup-tips", - "titleZh": "启动提示", - "titleEn": "Startup tips", - "descriptionZh": "启动后显示功能提示。", - "descriptionEn": "Show product tips after startup.", - "valueSchema": { - "type": "boolean" - } } ], "searchTerms": [ @@ -25307,10 +25192,10 @@ "prevent sleep", "开机启动与自动更新", "窗口关闭和托盘行为", - "任务完成、权限请求与启动提示通知", + "任务完成与权限请求通知", "Launch-at-login and automatic updates", "Window close and tray behavior", - "Completion, permission, and startup-tip notifications", + "Completion and permission notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", @@ -25321,10 +25206,8 @@ "Choose whether OpenBitFun prevents the computer from sleeping while it runs", "选择关闭主窗口时退出、最小化到托盘或每次询问", "Choose whether closing the main window quits, minimizes to tray, or asks each time", - "分别控制任务完成、权限请求和启动提示通知", - "Control completion, permission-request, and startup-tip notifications independently", - "查看、标记已读、忽略或永久隐藏产品公告与提示", - "Review, mark seen, dismiss, or permanently hide product announcements and tips", + "分别控制任务完成和权限请求通知", + "Control completion and permission-request notifications independently", "关闭自动更新", "以后关闭窗口时最小化到托盘", "打开常规设置", diff --git a/src/web-ui/src/app/scenes/settings/pages/application/GeneralSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/application/GeneralSettingsPage.tsx index bd0865cc2b..bc9f61d820 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/GeneralSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/GeneralSettingsPage.tsx @@ -405,10 +405,9 @@ function WindowBehaviorSetting() { function NotificationSettings() { const { t } = useTranslation('settings/application'); - const seed = useConfigSeed(['app.notifications.dialog_completion_notify', 'app.notifications.permission_request_notify', 'app.notifications.enable_startup_tips']); + const seed = useConfigSeed(['app.notifications.dialog_completion_notify', 'app.notifications.permission_request_notify']); const [dialogNotify, setDialogNotify] = useState(seed.get('app.notifications.dialog_completion_notify', true) !== false); const [permissionRequestNotify, setPermissionRequestNotify] = useState(seed.get('app.notifications.permission_request_notify', true) !== false); - const [startupTips, setStartupTips] = useState(seed.get('app.notifications.enable_startup_tips', true) !== false); const [loading, setLoading] = useState(!seed.loaded); const [loadFailed, setLoadFailed] = useState(false); const [saving, setSaving] = useState(false); @@ -418,14 +417,12 @@ function NotificationSettings() { setLoading(!seed.loaded); setLoadFailed(false); try { - const [notify, permissionNotify, tips] = await Promise.all([ + const [notify, permissionNotify] = await Promise.all([ configManager.getOptionalConfig('app.notifications.dialog_completion_notify'), configManager.getOptionalConfig('app.notifications.permission_request_notify'), - configManager.getOptionalConfig('app.notifications.enable_startup_tips'), ]); setDialogNotify(notify !== false); setPermissionRequestNotify(permissionNotify !== false); - setStartupTips(tips !== false); } catch (error) { log.error('Failed to load notification preferences', error); setLoadFailed(true); @@ -464,19 +461,6 @@ function NotificationSettings() { } }; - const handleStartupTipsToggle = async (checked: boolean) => { - setSaving(true); - try { - await configManager.setConfig('app.notifications.enable_startup_tips', checked); - setStartupTips(checked); - setMessage({ type: 'success', text: t('notifications.messages.saveSuccess') }); - } catch { - setMessage({ type: 'error', text: t('notifications.messages.saveFailed') }); - } finally { - setSaving(false); - } - }; - if (loading) { return ; } @@ -518,17 +502,6 @@ function NotificationSettings() { disabled={saving} /> - - { void handleStartupTipsToggle(e.target.checked); }} - disabled={saving} - /> - ); } diff --git a/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts b/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts index 194fbef8a0..7f24a7938b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts +++ b/src/web-ui/src/app/scenes/settings/settingsDataPreload.ts @@ -4,7 +4,7 @@ import type { SettingsPageId } from './settingsTypes'; // Read-only configuration owned by these destinations. Runtime probes and secrets // remain with their page owners; hovering must never start a service or a scan. const CONFIG_PATHS: Partial> = { - 'application.general': ['app.close_button_behavior', 'app.notifications.dialog_completion_notify', 'app.notifications.permission_request_notify', 'app.notifications.enable_startup_tips'], + 'application.general': ['app.close_button_behavior', 'app.notifications.dialog_completion_notify', 'app.notifications.permission_request_notify'], 'application.pet': ['app.ai_experience'], 'application.input': ['app.ai_experience', 'app.flow_chat.auto_show_selection_toolbar'], 'ai.session-memory': ['app.flow_chat', 'app.ai_experience', 'ai.models', 'ai.task_models', 'memories'], diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts index 84c696cd72..ae7e8a6f42 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.appearance.ts @@ -8,7 +8,8 @@ export const exportImageAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'root' }, { id: 'header' }, { id: 'logo' }, { id: 'title' }, { id: 'timestamp' }, { id: 'user' }, { id: 'assistant' }, { id: 'round' }, { id: 'text' }, { id: 'thinking' }, { id: 'tool' }, { id: 'footer' }, - { id: 'trigger' }, + { id: 'trigger' }, { id: 'options' }, { id: 'optionCard' }, { id: 'optionContent' }, + { id: 'optionLabel' }, { id: 'optionPreview' }, ], states: [{ id: 'exporting', selector: { kind: 'self', suffix: '[data-openbitfun-state~="exporting"]' } }], }; diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss index 1f2b862a40..680a4137ad 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.scss @@ -1,26 +1,78 @@ /* Export image styling aligned with FlowChat theme variables. */ +@use '../../transcript-layout' as transcriptLayout; // Export wrapper .export-image-wrapper { overflow: visible; } -.export-image-menu { - position: fixed; - z-index: var(--openbitfun-layer-popover); +.export-image-options { + display: grid; + inline-size: calc(448px + var(--openbitfun-space-3)); + max-inline-size: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 168px; + gap: var(--openbitfun-space-3); + margin-inline: auto; +} + +.export-image-option-content { + justify-content: center; + gap: var(--openbitfun-space-1); +} + +.export-image-option-card { + min-block-size: 0; + cursor: pointer; + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + @media (hover: hover) { + &:hover { + background: var(--openbitfun-color-action-neutral-surface-hover); + } + } + + &:has(:focus-visible) { + background: var(--openbitfun-color-action-neutral-surface-hover); + } + + &:active { + background: var(--openbitfun-color-action-neutral-surface-pressed); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.export-image-preview { + display: block; + inline-size: 100%; + block-size: 104px; + flex-shrink: 0; + color: var(--openbitfun-color-content-secondary); +} + +.export-image-option-label { + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); + line-height: var(--openbitfun-type-label-md-line-height); + letter-spacing: var(--openbitfun-type-label-md-letter-spacing); + text-align: center; } // Export content container .export-content { - /* Width is set dynamically in JS to match the live chat pane width. - Padding matches ModelRoundItem (3rem ≈ 48px each side) so the - effective content width is identical to the live chat. */ - padding: 24px 48px; + @include transcriptLayout.content-column; + + // Preserve the transcript inset while adding quiet framing above/below. + padding-block: var(--openbitfun-space-6); background: var(--openbitfun-color-surface-scene); box-sizing: border-box; - font-family:var(--openbitfun-type-body-sm-font-family); - /* Use a fixed 800px layout context so text wraps at exactly the same - width regardless of the wrapper's position or viewport. */ + font-family: var(--openbitfun-type-body-sm-font-family); + // The capture wrapper sets the live pane width for container-aware text layout. container-type: inline-size; /* Prevent unwanted wrapping of short inline text */ @@ -30,7 +82,6 @@ [data-openbitfun-component='flow-chat-tool-card'][data-openbitfun-part='content'], [data-openbitfun-component='flow-chat-tool-card'][data-openbitfun-part='extra'], .export-content__title, - .export-content__subtitle, .export-content__timestamp, .export-content__footer span, .export-content__footer-brand { @@ -64,17 +115,13 @@ &__header { display: flex; align-items: center; - gap: 12px; - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid var(--openbitfun-color-border-default); + gap: var(--openbitfun-space-2); + margin-block-end: var(--openbitfun-space-8); } &__logo { - width: 32px; - height: 32px; - border-radius: 8px; - object-fit: contain; + inline-size: var(--openbitfun-space-6); + block-size: var(--openbitfun-space-6); flex-shrink: 0; } @@ -87,29 +134,24 @@ display: block; } - &__title-group { - display: flex; - flex-direction: column; - gap: 2px; - flex-shrink: 0; - } - &__title { - font-size: var(--openbitfun-type-flow-title-font-size); + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-label-md-line-height); + letter-spacing: var(--openbitfun-type-label-md-letter-spacing); color: var(--openbitfun-color-content-primary); display: inline-block; } - &__subtitle { - font-size: var(--openbitfun-type-flow-support-font-size); - color: var(--openbitfun-color-content-muted); - display: inline-block; - } - &__timestamp { - margin-left: auto; - font-size: var(--openbitfun-type-flow-support-font-size); + margin-inline-start: auto; + font-family: var(--openbitfun-type-flow-meta-font-family); + font-size: var(--openbitfun-type-flow-meta-font-size); + font-weight: var(--openbitfun-type-flow-meta-font-weight); + line-height: var(--openbitfun-type-flow-meta-line-height); + letter-spacing: var(--openbitfun-type-flow-meta-letter-spacing); + font-variant-numeric: tabular-nums; color: var(--openbitfun-color-content-muted); display: inline-block; flex-shrink: 0; @@ -117,26 +159,32 @@ // ===== User message ===== &__user-section { - margin-bottom: 16px; + margin-block-end: var(--openbitfun-space-6); } &__user-bubble { - background: var(--openbitfun-color-action-neutral-surface-hover); - border: 1px solid var(--openbitfun-color-border-default); - border-radius: 12px; - padding: 14px 16px; - font-size: var(--openbitfun-type-body-md-font-size); + inline-size: fit-content; + max-inline-size: var(--openbitfun-control-flow-chat-user-message-max-width); + margin-inline: auto 0; + padding: var(--openbitfun-control-flow-chat-user-message-padding-block) var(--openbitfun-control-flow-chat-user-message-radius); + border: none; + border-radius: var(--openbitfun-control-flow-chat-user-message-radius); + background: var(--openbitfun-color-action-quiet-hover); + font-family: var(--openbitfun-type-flow-control-font-family); + font-size: var(--openbitfun-type-flow-control-font-size); + font-weight: var(--openbitfun-type-flow-control-font-weight); line-height: var(--openbitfun-type-flow-body-line-height); + letter-spacing: var(--openbitfun-type-flow-control-letter-spacing); color: var(--openbitfun-color-content-primary); white-space: pre-wrap !important; word-break: break-word; - word-wrap: break-word; overflow-wrap: break-word; + box-sizing: border-box; } // ===== AI response ===== &__ai-section { - margin-bottom: 16px; + min-inline-size: 0; } &__model-round { @@ -189,14 +237,18 @@ // ===== Footer ===== &__footer { - margin-top: 20px; - padding-top: 14px; - border-top: 1px solid var(--openbitfun-color-border-default); + margin-block-start: var(--openbitfun-space-8); + padding-block-start: var(--openbitfun-space-4); + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); display: flex; align-items: center; justify-content: center; - gap: 6px; - font-size: var(--openbitfun-type-flow-support-font-size); + gap: var(--openbitfun-space-1); + font-family: var(--openbitfun-type-flow-meta-font-family); + font-size: var(--openbitfun-type-flow-meta-font-size); + font-weight: var(--openbitfun-type-flow-meta-font-weight); + line-height: var(--openbitfun-type-flow-meta-line-height); + letter-spacing: var(--openbitfun-type-flow-meta-letter-spacing); color: var(--openbitfun-color-content-muted); white-space: nowrap; @@ -208,8 +260,7 @@ } &__footer-brand { - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-secondary); + color: inherit; white-space: nowrap; } } diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index 8b809346ee..b0cbb8c8ea 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -4,15 +4,14 @@ * Uses modern-screenshot (fork of html-to-image with better CSS var / font / CORS handling). */ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { FlowChatStore } from '../../store/FlowChatStore'; import { notificationService } from '@/shared/notification-system'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; -import { subscribeOverlayInteraction, createOverlayPortal, Button, Icon, IconButton, Menu, MenuItem, Tooltip } from '@openbitfun/ui'; +import { Button, Card, CardBody, CardHeader, Checkbox, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, IconButton, Tooltip } from '@openbitfun/ui'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import type { DialogTurn, FlowTextItem, FlowToolItem, FlowThinkingItem } from '../../types/flow-chat'; import { i18nService, useI18n } from '@/infrastructure/i18n'; import { workspaceAPI } from '@/infrastructure/api'; @@ -22,6 +21,8 @@ import { withTimeout } from '@/shared/utils/timing'; import { downloadDir, join } from '@tauri-apps/api/path'; import { writeFile } from '@tauri-apps/plugin-fs'; import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; +import { hasComposerPresentationReferences, parseComposerPresentation } from '../../utils/composerPresentation'; +import { UserMessagePresentationContent, UserMessageTextContent } from './UserMessagePresentationContent'; import './ExportImageButton.scss'; const log = createLogger('ExportImageButton'); @@ -71,6 +72,11 @@ function validateDataUrl(dataUrl: string): void { interface ExportImageButtonProps { turnId: string; className?: string; + triggerRef?: React.RefObject; + renderTrigger?: (controls: { + isExporting: boolean; + onOpenDialog: () => void; + }) => React.ReactNode; } // Exported content renderer. @@ -82,7 +88,61 @@ interface ExportContentProps { // Marker class on the logo placeholder so we can locate it for canvas compositing. const LOGO_PLACEHOLDER_CLASS = 'export-content__logo-placeholder'; -const ExportContent: React.FC = ({ dialogTurn, expandThinking }) => { +function ExportImagePreview({ expandThinking }: { expandThinking: boolean }) { + const answerY = expandThinking ? 88 : 55; + + return ( + + ); +} + +export const ExportContent: React.FC = ({ dialogTurn, expandThinking }) => { + const presentation = parseComposerPresentation(dialogTurn.userMessage?.metadata?.composerPresentation); + return (
@@ -95,10 +155,7 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin data-openbitfun-product-part="logo" aria-label="OpenBitFun" /> -
-
OpenBitFun
-
{i18nService.t('flow-chat:exportImage.subtitle').replace(/ /g, '\u00A0')}
-
+
OpenBitFun
{i18nService.formatDate(new Date())}
@@ -107,7 +164,9 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin {dialogTurn.userMessage?.content && (
- {dialogTurn.userMessage.content} + {hasComposerPresentationReferences(presentation) + ? + : }
)} @@ -134,17 +193,18 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin ); } } else if (item.type === 'thinking') { + if (!expandThinking) return null; const thinkingItem = item as FlowThinkingItem; return ( -
- +
); @@ -163,10 +223,8 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin
- {i18nService.t('flow-chat:exportImage.poweredBy').replace(/ /g, '\u00A0')} + {i18nService.t('flow-chat:exportImage.poweredBy')} OpenBitFun - • - {i18nService.t('flow-chat:exportImage.aiAssistant').replace(/ /g, '\u00A0')}
); @@ -174,43 +232,28 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin export const ExportImageButton: React.FC = ({ turnId, - className = '' + className = '', + triggerRef, + renderTrigger, }) => { const { t } = useI18n('flow-chat'); const [isExporting, setIsExporting] = useState(false); - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [expandThinking, setExpandThinking] = useState(false); const buttonRef = useRef(null); - const menuRef = useRef(null); - const menuLayout = useAnchoredPopoverPosition({ - open: isMenuOpen, - anchorRef: buttonRef, - popoverRef: menuRef, - preferredPlacement: 'top', - alignment: 'end', - gap: 4, - }); - - useEffect(() => { - if (!isMenuOpen) return; - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (!buttonRef.current?.contains(target) && !menuRef.current?.contains(target)) { - setIsMenuOpen(false); - } - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setIsMenuOpen(false); - }; - const removeOverlayMousedown0 = subscribeOverlayInteraction(menuRef, 'mousedown', handleClickOutside); - const removeOverlayKeydown1 = subscribeOverlayInteraction(menuRef, 'keydown', handleKeyDown); - return () => { - removeOverlayMousedown0?.(); - removeOverlayKeydown1?.(); - }; - }, [isMenuOpen]); + const compactOptionRef = useRef(null); + const thinkingOptionRef = useRef(null); // Ref guard to prevent double-invocation while state update is pending. const isExportingRef = useRef(false); + const openExportDialog = useCallback(() => { + if (isExportingRef.current) return; + // Restore to the resident trigger rather than the menu item that is closing. + (triggerRef ?? buttonRef).current?.focus({ preventScroll: true }); + setExpandThinking(false); + setIsDialogOpen(true); + }, [triggerRef]); + // Resolve the DialogTurn and its session title by turn id. const getDialogTurn = useCallback((): { turn: DialogTurn; sessionTitle: string } | null => { const flowChatStore = FlowChatStore.getInstance(); @@ -225,7 +268,7 @@ export const ExportImageButton: React.FC = ({ const handleExport = useCallback(async (expandThinking: boolean) => { if (isExportingRef.current) return; - setIsMenuOpen(false); + setIsDialogOpen(false); isExportingRef.current = true; setIsExporting(true); @@ -583,45 +626,116 @@ export const ExportImageButton: React.FC = ({ return ( <> - - setIsMenuOpen(current => !current)} - disabled={isExporting} - aria-haspopup="menu" - aria-expanded={isMenuOpen} - aria-label={isExporting - ? i18nService.t('flow-chat:exportImage.exporting') - : i18nService.t('flow-chat:exportImage.exportToImage')} - icon={isExporting - ? - : } - /> - - {isMenuOpen && createOverlayPortal( - - void handleExport(false)}> - {t('exportImage.collapsedThinking')} - - void handleExport(true)}> - {t('exportImage.expandedThinking')} - - , - getAppearanceOverlayHost(), + {renderTrigger ? renderTrigger({ isExporting, onOpenDialog: openExportDialog }) : ( + + } + /> + )} + + + + {t('exportImage.exportToImage')} + + + + +
{ + let next: boolean; + if (['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key)) next = !expandThinking; + else if (event.key === 'Home') next = false; + else if (event.key === 'End') next = true; + else return; + event.preventDefault(); + setExpandThinking(next); + (next ? thinkingOptionRef : compactOptionRef).current?.focus({ preventScroll: true }); + }} + > + { + setExpandThinking(false); + compactOptionRef.current?.focus({ preventScroll: true }); + }} + > + setExpandThinking(false)} + data-testid="export-image-compact" + /> + )} /> + + + {t('exportImage.collapsedThinking')} + + + { + setExpandThinking(true); + thinkingOptionRef.current?.focus({ preventScroll: true }); + }} + > + setExpandThinking(true)} + data-testid="export-image-thinking" + /> + )} /> + + + {t('exportImage.expandedThinking')} + + +
+
+ + + + +
); }; diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 66614883d1..4b9f52024c 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -18,6 +18,7 @@ each missing what the other had. |---|---| | `../../timeline/hostQueueProjection.test.ts`, `../../timeline/useHostQueueTimeline.test.tsx`, `../HostPendingQueuePanel.test.tsx` | host-accepted preview, canonical handoff/event ordering, stable keys, blocked recovery, old-host fallback, history-window visibility and device lease cleanup; fixtures only, no live remote transport evidence | | `TurnFooter.test.tsx`, `modelRoundItemMemo.test.ts` | latest-turn identity, hover/focus across virtual rows, lease cleanup, no projection churn and the existing completion reveal gate; behavior only, not visual proof | +| `MessageExportControls.test.tsx` | flat copy/export menu, illustrated checkbox-card selection by pointer and keyboard, complete user text/reference rendering, omission or expansion of thinking, minimal branding, confirmation, pending/error feedback, cancel/Escape/outside dismissal and resident-trigger focus restoration; local DOM behavior, not image fidelity or remote-host validation | | `../../../shared/ui/Tooltip.test.tsx` | shared metric click opening, immediate scroll dismissal, delayed-open cancellation, viewport-exit observer cleanup and no automatic reopening; DOM/observer behavior only, not native scrolling or visual proof | | `../../utils/turnUsageMetrics.test.ts` | cache denominator, whole-turn output rate, speed boundaries, invalid and unavailable telemetry | | `../../services/flow-chat-manager/EventHandlerModule.test.ts`, `../../services/flow-chat-manager/PersistenceModule.test.ts`, `../../store/FlowChatStore.test.ts` | live cache aggregation, optional-field persistence and legacy/current hydration; no remote-host claim | @@ -28,7 +29,7 @@ each missing what the other had. | `../../selection/conversationExcerptEditing.test.ts` | draft/visible/queue edit and delete ownership, preserved prompt context and attachments, legacy payload handling, sending guards and sent snapshots with no mutation capability | | `../../services/flow-chat-manager/PendingQueueModule.test.ts` | persisted payload edits preserve queue order/identity, images and device isolation; sending entries reject edits | | `../../../infrastructure/markdown/useStreamingTextReveal.test.tsx` | appended-glyph-only fading, independent batch clocks and renderers, history/remount stability, stream completion and reduced motion | -| `UserMessageItem.test.tsx` | sent and reloaded MCP references, mixed reference capsules, failed-message presentation, and message actions | +| `UserMessageItem.test.tsx` | sent and reloaded MCP references, mixed reference capsules, failed-message presentation, shared edit/rollback menu, Escape focus return, disabled reasons, and existing message action flows; local DOM fixtures, not remote transport evidence | | `UserMessageItemActions.test.ts` | metadata visibility, bubble outsets from the shared reading column, and normal-flow action layout | | `../../services/submittedMessagePresentation.test.ts` | explicit-send receipts, one-shot claims, expiry, device activation isolation, and initial status paint delay | | `useSubmittedMessageMotion.test.tsx` | submission clock, remount stability, StrictMode, reduced motion, focus, and surface-change cancellation; lifecycle only, not visual acceptance | @@ -92,6 +93,15 @@ Its permission control reads the parent and becomes editable after submission. Peers advertise `btw_initial_model_selection_v1` before showing those draft settings as editable. Older hosts retain inherited settings and readable quotes. +For completed-turn image export, check both option cards in light/dark and +high-contrast themes: hover, press, and keyboard focus change the neutral fill +without changing size or selection; only the checkbox indicates the selected +output. Check reduced motion and touch input separately. Check the captured image's +user bubble against the live message, full long text/reference labels, minimal +brand/date header and attribution footer, and absence of all thinking items/icons +in compact output. The same presentation +on remote-workspace and peer-host sessions remains a real-host check. + **Agents must not perform UI interaction verification.** Report these as pending unless a human confirms them. They are grouped so that adding a check to one group does not renumber the others. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index be33961aa7..cdceb2e938 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -47,6 +47,30 @@ The resident completed-turn footer keeps `FlowChatTurnMetrics` on the left and elapsed time with the existing actions on the right. Only elapsed time is shown inline; its shared Tooltip exposes the completion timestamp and elapsed time. Metric typography and Lucide glyph sizing belong to `@openbitfun/ui`. +The completed-turn copy menu contains one export-as-image action alongside the +copy actions, without sections. It opens a shared Dialog with two selectable +cards with matching 224×168px footprints, reducing only their width in narrow +panes. Public CardHeader actions and CardBody slots compose the checkbox and +preview without overriding Checkbox anatomy. Whole-card activation selects the +shared Checkbox; its checked mark indicates +the single selected output without changing the card border. Hover and keyboard +focus use the semantic neutral hover surface; pressing uses its pressed surface. +Only the background transitions, using shared fast motion with a reduced-motion +fallback, so interaction never changes the card geometry. Equal, compact +preview frames consume semantic surface/content/accent tokens directly and +distinguish a reply-only preview from a larger accent-tinted +expanded thinking panel and the reply content. Pointer activation and arrow/Home/End +keys select an output without starting capture. Export requires confirmation. Cancel and Escape leave the +transcript untouched and restore focus to the resident trigger. The trigger shows +export progress and stays disabled until capture finishes; the exporter stays +mounted when the menu and Dialog close. There is no separate image action in the footer. +The capture uses the same transcript inset and user-bubble tokens as the live +message, with right alignment, borderless quiet fill and complete text rather +than the live view's three-line clamp. Persisted reference capsules reuse the +live message's content renderer. Compact output omits thinking items entirely, +including their icons; the other output renders expanded thinking in item order. +The small brand/date header and muted attribution footer use semantic typography, +spacing and divider tokens, without an assistant slogan or subtitle. Each number has its own hover/focus/click capsule and details card. Clicking opens immediately and keeps the card open until another trigger click, an outside press, Escape, or scrolling; native button triggers also support keyboard activation. `FlowChatMetric` @@ -323,6 +347,13 @@ User-message timestamps and actions occupy a normal-flow meta row below the bubble. Its full height, including the 28px action targets, belongs to the measured message even when no valid timestamp is available. The timestamp and actions remain visible at rest, without requiring hover or keyboard focus. +Editing and rollback share one edit-icon menu trigger, labelled "Edit and rollback". +The public MenuPopover owns anchoring, dismissal, keyboard navigation and focus +return. Opening it performs no history mutation; selecting a row enters the +existing edit or rollback flow. Each row retains its visibility gates and disabled +reason, including remote-workspace and detached-dispatch restrictions. An open +menu keeps the resident action cluster visible while focus moves into the portal. +The menu leaves measured row geometry and viewport ownership unchanged. `_transcript-layout.scss` owns the reading-column inset shared by user-message shells, model rounds, Explore regions, and the runtime-status footer. The messages viewport enables a shared outer column rule for virtual rows and diff --git a/src/web-ui/src/flow_chat/components/modern/MessageExportControls.test.tsx b/src/web-ui/src/flow_chat/components/modern/MessageExportControls.test.tsx index b928b29b17..417e4eb119 100644 --- a/src/web-ui/src/flow_chat/components/modern/MessageExportControls.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/MessageExportControls.test.tsx @@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AppearanceCompiler } from '@/infrastructure/appearance/compiler/AppearanceCompiler'; import { AppearanceRegistry } from '@/infrastructure/appearance/registry/AppearanceRegistry'; import { APPEARANCE_SCHEMA_VERSION, type AppearancePackage } from '@/infrastructure/appearance/types'; -import type { ModelRound } from '../../types/flow-chat'; -import { ExportImageButton } from './ExportImageButton'; +import type { DialogTurn, FlowThinkingItem, ModelRound } from '../../types/flow-chat'; +import { ExportContent, ExportImageButton } from './ExportImageButton'; import { ModelRoundItem } from './ModelRoundItem'; import { exportImageAppearanceDescriptor } from './ExportImageButton.appearance'; import { modelRoundItemAppearanceDescriptor } from './ModelRoundItem.appearance'; @@ -28,7 +28,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); vi.mock('@/infrastructure/i18n', () => ({ - i18nService: { t: (key: string) => key }, + i18nService: { t: (key: string) => key, formatDate: () => 'Export date' }, useI18n: () => ({ t: (key: string) => key, formatDate: () => '12:00' }), })); vi.mock('../../store/FlowChatStore', () => ({ @@ -52,7 +52,11 @@ vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ vi.mock('@/infrastructure/api', () => ({ workspaceAPI: {} })); vi.mock('../FlowTextBlock', () => ({ FlowTextBlock: () => Answer })); vi.mock('../FlowToolCard', () => ({ FlowToolCard: () => null })); -vi.mock('../../tool-cards/ModelThinkingDisplay', () => ({ ModelThinkingDisplay: () => null })); +vi.mock('../../tool-cards/ModelThinkingDisplay', () => ({ + ModelThinkingDisplay: ({ thinkingItem, forceExpanded }: { thinkingItem: FlowThinkingItem; forceExpanded: boolean }) => ( + {thinkingItem.content} + ), +})); vi.mock('../subagent/SubagentProjectionView', () => ({ SubagentProjectionView: () => null })); vi.mock('./ForkSessionButton', () => ({ ForkSessionButton: () => null })); vi.mock('./FlowChatContext', () => ({ @@ -138,46 +142,143 @@ describe('message copy and image export controls', () => { vi.useRealTimers(); }); - it('anchors the export menu to the button and retains inside/outside and Escape handling', () => { + it.each([false, true])('exports complete user text and includes thinking only when requested (%s)', expandThinking => { + const userText = 'First line\nSecond line\nThird line\nFourth line\nFinal line'; + const turn: DialogTurn = { + id: 'turn-1', sessionId: 'session-1', status: 'completed', startTime: 1, + userMessage: { id: 'user-1', content: userText, timestamp: 1 }, + modelRounds: [{ + ...round, + items: [...round.items, { + id: 'thinking-1', type: 'thinking', timestamp: 0, status: 'completed', + content: 'Private thinking', isStreaming: false, + }], + }], + }; + act(() => root.render()); + expect(container.querySelector('.export-content__user-bubble')?.textContent).toBe(userText); + expect(container.querySelector('[data-openbitfun-product-part="text"]')?.textContent).toBe('Answer'); + expect(container.querySelector('.export-content__subtitle')).toBeNull(); + expect(container.textContent).not.toContain('exportImage.aiAssistant'); + expect(container.textContent).not.toContain('exportImage.subtitle'); + expect(container.querySelector('.export-content__timestamp')?.textContent).toBe('Export date'); + const thinking = container.querySelector('[data-openbitfun-product-part="thinking"]'); + if (expandThinking) { + expect(thinking?.textContent).toBe('Private thinking'); + expect(thinking?.querySelector('[data-expanded="true"]')).not.toBeNull(); + expect(thinking?.compareDocumentPosition(container.querySelector('[data-openbitfun-product-part="text"]')!)) + .toBe(Node.DOCUMENT_POSITION_FOLLOWING); + } else { + expect(thinking).toBeNull(); + expect(container.querySelector('[data-testid="exported-thinking"]')).toBeNull(); + expect(container.textContent).not.toContain('Private thinking'); + } + }); + + it('uses the live user-message renderer for persisted reference capsules', () => { + const turn: DialogTurn = { + id: 'turn-1', sessionId: 'session-1', status: 'completed', startTime: 1, + userMessage: { + id: 'user-1', content: 'Read [$pdf]', timestamp: 1, + metadata: { composerPresentation: { + version: 1, + segments: [ + { kind: 'text', text: 'Read ' }, + { kind: 'inline-token', token: '[$pdf]', tokenType: 'skill', label: 'pdf' }, + ], + } }, + }, + modelRounds: [round], + }; + act(() => root.render()); + const user = container.querySelector('.export-content__user-bubble')!; + expect(user.querySelector('.message-reference-capsule--skill')?.textContent).toBe('pdf'); + expect(user.textContent).toBe('Read pdf'); + expect(user.textContent).not.toContain('[$pdf]'); + }); + + it('opens export parameters without starting capture and dismisses with Escape or an outside press', () => { act(() => root.render()); const trigger = container.querySelector('.model-round-item__export-btn')!; act(() => trigger.click()); - expect(mocks.anchor).toBe(trigger); - expect(trigger.getAttribute('aria-expanded')).toBe('true'); - expect(document.querySelector('.export-image-menu')).not.toBeNull(); - act(() => trigger.querySelector('[data-openbitfun-part="icon"]')! - .dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))); + expect(trigger.getAttribute('aria-haspopup')).toBe('dialog'); expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(document.querySelector('[data-testid="export-image-dialog"]')).not.toBeNull(); + expect(document.activeElement).toBe(document.querySelector('[data-testid="export-image-compact"]')); + expect(mocks.store).not.toHaveBeenCalled(); act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); - expect(document.querySelector('.export-image-menu')).toBeNull(); + act(() => vi.advanceTimersByTime(180)); + expect(document.querySelector('[data-testid="export-image-dialog"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); act(() => trigger.click()); - act(() => document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))); - expect(document.querySelector('.export-image-menu')).toBeNull(); + const overlay = document.querySelector('[data-testid="export-image-dialog"]')!.parentElement!; + act(() => overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); + act(() => vi.advanceTimersByTime(180)); + expect(document.querySelector('[data-testid="export-image-dialog"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(mocks.store).not.toHaveBeenCalled(); }); - it.each(['exportImage.collapsedThinking', 'exportImage.expandedThinking'])( - 'retains pending feedback and recovers after a missing Turn through %s', async label => { + it.each(['compact', 'thinking'])( + 'retains pending feedback and recovers after a missing Turn with %s output', async variant => { const style = compileLegacyPackage(); act(() => root.render()); const trigger = container.querySelector('.model-round-item__export-btn')!; act(() => trigger.click()); - const action = Array.from(document.querySelectorAll('.export-image-menu button')) - .find(button => button.textContent === label)!; - act(() => action.click()); + act(() => document.querySelector(`[data-testid="export-image-${variant}"]`)!.click()); + expect(mocks.store).not.toHaveBeenCalled(); + act(() => document.querySelector('[data-testid="export-image-confirm"]')!.click()); expect(trigger.disabled).toBe(true); - expect(trigger.querySelector('.spinning')).not.toBeNull(); - expect(trigger.getAttribute('data-loading')).toBe('false'); + expect(trigger.getAttribute('aria-busy')).toBe('true'); + expect(trigger.getAttribute('data-loading')).toBe('true'); expectCompiledRuleMatches(style, trigger, '0.7'); act(() => trigger.click()); - expect(document.querySelector('.export-image-menu')).toBeNull(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); await act(async () => vi.advanceTimersByTimeAsync(50)); expect(mocks.store).toHaveBeenCalledTimes(1); expect(mocks.error).toHaveBeenCalledExactlyOnceWith('flow-chat:exportImage.dialogNotFound'); expect(trigger.disabled).toBe(false); - expect(trigger.querySelector('.spinning')).toBeNull(); + expect(trigger.getAttribute('aria-busy')).toBeNull(); }, ); + it('selects illustrated checkbox cards through their preview and arrow keys without starting export', () => { + act(() => root.render()); + act(() => container.querySelector('.model-round-item__export-btn')!.click()); + const dialog = document.querySelector('[data-testid="export-image-dialog"]')!; + const compact = dialog.querySelector('[data-testid="export-image-compact"]')!; + const thinking = dialog.querySelector('[data-testid="export-image-thinking"]')!; + expect(dialog.querySelectorAll('[data-openbitfun-component="card"]')).toHaveLength(2); + const compactCard = compact.closest('[data-openbitfun-component="card"]')!; + const thinkingCard = thinking.closest('[data-openbitfun-component="card"]')!; + expect(compact.type).toBe('checkbox'); + expect(thinking.type).toBe('checkbox'); + expect(compactCard.querySelector('[data-preview-thinking]')?.getAttribute('data-preview-thinking')).toBe('collapsed'); + expect(thinkingCard.querySelector('[data-preview-thinking]')?.getAttribute('data-preview-thinking')).toBe('expanded'); + act(() => thinkingCard.querySelector('[data-preview-thinking]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(thinking.checked).toBe(true); + expect(compact.checked).toBe(false); + act(() => thinking.click()); + expect(thinking.checked).toBe(true); + expect(compact.checked).toBe(false); + const press = (key: string) => act(() => (document.activeElement ?? thinking) + .dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))); + act(() => thinking.focus()); + press('ArrowLeft'); + expect(document.activeElement).toBe(compact); + expect(compact.checked).toBe(true); + expect(compact.tabIndex).toBe(0); + expect(thinking.tabIndex).toBe(0); + press('ArrowRight'); + expect(document.activeElement).toBe(thinking); + press('Home'); + expect(document.activeElement).toBe(compact); + press('End'); + expect(document.activeElement).toBe(thinking); + expect(thinking.checked).toBe(true); + expect(mocks.store).not.toHaveBeenCalled(); + }); + it.each(['full', 'result'] as const)('copies %s once and keeps legacy appearance on the copied button', async scope => { const style = compileLegacyPackage(); act(() => root.render()); @@ -195,6 +296,61 @@ describe('message copy and image export controls', () => { expect(trigger.classList.contains('copied')).toBe(false); }); + it.each(['compact', 'thinking'] as const)('confirms %s image parameters after opening the dialog from the copy menu', async variant => { + act(() => root.render()); + const trigger = container.querySelector('[data-testid="model-round-copy-btn"]')!; + expect(container.querySelector('.model-round-item__export-btn')).toBeNull(); + act(() => trigger.click()); + const menu = document.querySelector('[data-testid="model-round-copy-menu"]')!; + expect(menu.textContent).toContain('exportImage.exportToImage'); + expect(menu.querySelectorAll('button')).toHaveLength(3); + expect(menu.querySelector('[data-openbitfun-component="menu-section"]')).toBeNull(); + act(() => menu.querySelector('[data-testid="model-round-export-image"]')!.click()); + expect(document.querySelector('[data-testid="model-round-copy-menu"]')).toBeNull(); + const dialog = document.querySelector('[data-testid="export-image-dialog"]')!; + expect(dialog.getAttribute('role')).toBe('dialog'); + expect(dialog.querySelector('[data-testid="export-image-compact"]')!.checked).toBe(true); + act(() => dialog.querySelector(`[data-testid="export-image-${variant}"]`)!.click()); + expect(dialog.querySelector(`[data-testid="export-image-${variant}"]`)!.checked).toBe(true); + expect(mocks.store).not.toHaveBeenCalled(); + act(() => dialog.querySelector('[data-testid="export-image-confirm"]')!.click()); + expect(trigger.disabled).toBe(true); + expect(trigger.getAttribute('aria-busy')).toBe('true'); + expect(trigger.getAttribute('aria-label')).toBe('exportImage.exporting'); + act(() => trigger.click()); + expect(document.querySelector('[data-testid="model-round-copy-menu"]')).toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(50)); + expect(mocks.store).toHaveBeenCalledTimes(1); + expect(mocks.error).toHaveBeenCalledExactlyOnceWith('flow-chat:exportImage.dialogNotFound'); + expect(trigger.disabled).toBe(false); + expect(trigger.getAttribute('aria-busy')).toBeNull(); + expect(trigger.getAttribute('aria-label')).toBe('modelRound.copyDialog'); + expect(mocks.clipboard).not.toHaveBeenCalled(); + act(() => trigger.click()); + expect(document.querySelector('[data-testid="model-round-copy-menu"]')).not.toBeNull(); + }); + + it.each(['cancel', 'escape', 'outside'] as const)('dismisses export parameters through %s without exporting and returns focus to the copy button', async reason => { + act(() => root.render()); + const trigger = container.querySelector('[data-testid="model-round-copy-btn"]')!; + act(() => trigger.click()); + act(() => document.querySelector('[data-testid="model-round-export-image"]')!.click()); + const dialog = document.querySelector('[data-testid="export-image-dialog"]')!; + act(() => dialog.querySelector('[data-testid="export-image-thinking"]')!.click()); + act(() => { + if (reason === 'cancel') dialog.querySelector('[data-testid="export-image-cancel"]')!.click(); + else if (reason === 'escape') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + else dialog.parentElement!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + }); + await act(async () => vi.advanceTimersByTimeAsync(180)); + expect(document.querySelector('[data-testid="export-image-dialog"]')).toBeNull(); + expect(document.querySelector('[data-testid="model-round-copy-menu"]')).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(mocks.store).not.toHaveBeenCalled(); + expect(mocks.clipboard).not.toHaveBeenCalled(); + expect(mocks.error).not.toHaveBeenCalled(); + }); + it('keeps copy disabled and outside the tab order while typewriter output is catching up', () => { mocks.revealing = true; act(() => root.render()); diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index b7e6658457..7c7cf2ac27 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -782,55 +782,69 @@ export const ModelRoundItem = React.memo(
- {allowTranscriptExport &&
- - setIsCopyMenuOpen(current => !current)} - tabIndex={shouldRevealFooter ? 0 : -1} - disabled={!shouldRevealFooter} - aria-haspopup="menu" - aria-expanded={isCopyMenuOpen} - aria-label={copied ? t('modelRound.copiedDialog') : t('modelRound.copyDialog')} - data-testid="model-round-copy-btn" - data-openbitfun-product-component="model-round-item" data-openbitfun-product-part="action" data-openbitfun-state={copied ? 'copied' : undefined} - icon={} - /> - - - {isCopyMenuOpen && createOverlayPortal( - - void handleCopyScope('full')} - data-testid="model-round-copy-full" - > - {t('transcriptExport.copyFull')} - - void handleCopyScope('result')} - data-testid="model-round-copy-result" + {allowTranscriptExport && ( +
+ + setIsCopyMenuOpen(current => !current)} + tabIndex={shouldRevealFooter ? 0 : -1} + disabled={!shouldRevealFooter} + loading={isExporting} + aria-haspopup="menu" + aria-expanded={isCopyMenuOpen} + aria-label={isExporting ? t('exportImage.exporting') : copied ? t('modelRound.copiedDialog') : t('modelRound.copyDialog')} + data-testid="model-round-copy-btn" + data-openbitfun-product-component="model-round-item" data-openbitfun-product-part="action" data-openbitfun-state={copied ? 'copied' : undefined} + icon={} + /> + + + {isCopyMenuOpen && createOverlayPortal( + - {t('transcriptExport.copyResult')} - - , - getAppearanceOverlayHost(), - )} -
} - - {allowTranscriptExport && } + void handleCopyScope('full')} + data-testid="model-round-copy-full" + > + {t('transcriptExport.copyFull')} + + void handleCopyScope('result')} + data-testid="model-round-copy-result" + > + {t('transcriptExport.copyResult')} + + { + setIsCopyMenuOpen(false); + onOpenDialog(); + }} + aria-haspopup="dialog" + data-testid="model-round-export-image" + > + {t('exportImage.exportToImage')} + +
, + getAppearanceOverlayHost(), + undefined, + { ownerRef: copyButtonRef, open: isCopyMenuOpen }, + )} +
+ )} />}
diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index 994fb137eb..f389a3f6b4 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -169,6 +169,7 @@ } .user-message-item-shell:hover .user-message-item__actions, +.user-message-item__actions[data-menu-open], .user-message-item-shell:focus-within .user-message-item__actions { opacity: 1; pointer-events: auto; @@ -274,8 +275,7 @@ } .user-message-item__copy-btn, -.user-message-item__edit-btn, -.user-message-item__rollback-btn { +.user-message-item__edit-rollback-btn { @include transcriptLayout.metadata-action; // Keep the message action feedback and original pending indicator. background: transparent; @@ -457,8 +457,7 @@ .user-message-item__timestamp, .user-message-item__actions, .user-message-item__copy-btn, - .user-message-item__edit-btn, - .user-message-item__rollback-btn, + .user-message-item__edit-rollback-btn, .user-message-item__image-thumb, .user-message-item__lightbox-close { transition-duration: 0ms; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index c0198fa911..afe95e2d4f 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -215,6 +215,16 @@ describe('UserMessageItem steering tag', () => { let container: HTMLDivElement; let root: Root; + function openMessageActionMenu() { + const trigger = container.querySelector('.user-message-item__edit-rollback-btn'); + act(() => { trigger?.click(); }); + return trigger; + } + + function getMessageAction(action: 'edit' | 'rollback') { + return document.querySelector(`[role="menu"]:not([aria-hidden="true"]) [data-menu-id="${action}"]`); + } + beforeEach(() => { vi.clearAllMocks(); flowChatStoreMock.getState.mockReturnValue({ @@ -239,6 +249,8 @@ describe('UserMessageItem steering tag', () => { vi.stubGlobal('window', dom.window); vi.stubGlobal('document', dom.window.document); vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('Element', dom.window.Element); + vi.stubGlobal('Node', dom.window.Node); vi.stubGlobal('CustomEvent', dom.window.CustomEvent); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); @@ -322,8 +334,9 @@ describe('UserMessageItem steering tag', () => { , )); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__edit-btn')?.click(); + getMessageAction('edit')?.click(); }); const shell = container.querySelector('.user-message-item-shell'); @@ -448,8 +461,7 @@ describe('UserMessageItem steering tag', () => { if (status === 'failed') expect(container.querySelector('.user-message-item__steering-tag')?.textContent) .toContain('steering.statusNotApplied'); expect(container.querySelector('.user-message-item__steering-tag')?.textContent).toContain('hostQueue.attachments'); - expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); - expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull(); + expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull(); expect(container.querySelector('.user-message-item__copy-btn')).not.toBeNull(); }); @@ -462,8 +474,7 @@ describe('UserMessageItem steering tag', () => { )); expect(container.querySelector('.user-message-item__steering-tag')).toBeNull(); expect(container.textContent).toContain('Accepted instruction'); - expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); - expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull(); + expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull(); }); it('does not render a steering tag after steering is triggered', () => { @@ -746,13 +757,15 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull(); + openMessageActionMenu(); + expect(getMessageAction('rollback')).toBeNull(); }); - it('renders the rollback button for normal sessions when rollback is allowed', () => { + it('combines edit and rollback behind the edit icon for normal sessions', () => { activeSessionRef.current = { sessionId: 'main-session', sessionKind: 'normal', + workspaceId: workspaceRecords.local.id, dialogTurns: [ { id: 'turn-1', @@ -776,7 +789,23 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__rollback-btn')).not.toBeNull(); + expect(container.querySelectorAll('.user-message-item__actions button')).toHaveLength(2); + const trigger = openMessageActionMenu(); + expect(trigger?.getAttribute('aria-label')).toBe('message.editAndRollback'); + expect(trigger?.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger?.getAttribute('aria-expanded')).toBe('true'); + expect(trigger?.querySelector('[data-openbitfun-component="icon"][data-openbitfun-name="edit"]')).not.toBeNull(); + expect(getMessageAction('edit')).not.toBeNull(); + expect(getMessageAction('rollback')).not.toBeNull(); + expect(rollbackServiceMock.rollbackSessionToTurn).not.toHaveBeenCalled(); + expect(useMessageEditStore.getState().editingTurnId).toBeNull(); + + act(() => { + document.activeElement?.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(trigger?.getAttribute('aria-expanded')).toBe('false'); + expect(getMessageAction('edit')).toBeNull(); + expect(document.activeElement).toBe(trigger); }); it('restores image attachments to the composer after rollback', async () => { @@ -808,8 +837,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__rollback-btn')?.click(); + getMessageAction('rollback')?.click(); await Promise.resolve(); await Promise.resolve(); }); @@ -865,10 +895,9 @@ describe('UserMessageItem steering tag', () => { ); }); - const rollbackButton = container.querySelector( - '.user-message-item__rollback-btn', - ); - const editButton = container.querySelector('.user-message-item__edit-btn'); + openMessageActionMenu(); + const rollbackButton = getMessageAction('rollback'); + const editButton = getMessageAction('edit'); expect(rollbackButton?.disabled).toBe(true); expect(rollbackButton?.getAttribute('aria-label')).toContain(`message.rollbackDisabled${reason}`); @@ -911,7 +940,9 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); + openMessageActionMenu(); + expect(getMessageAction('edit')).toBeNull(); + expect(getMessageAction('rollback')).not.toBeNull(); }); it('keeps edit and rollback available for on-demand hydration in a partial history tail', () => { @@ -952,8 +983,9 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(false); - expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(false); + openMessageActionMenu(); + expect(getMessageAction('edit')?.disabled).toBe(false); + expect(getMessageAction('rollback')?.disabled).toBe(false); }); it('rolls back partial history by stable Turn identity without full hydration', async () => { @@ -1009,8 +1041,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__rollback-btn')?.click(); + getMessageAction('rollback')?.click(); await Promise.resolve(); await Promise.resolve(); }); @@ -1044,8 +1077,9 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(false); - expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(false); + openMessageActionMenu(); + expect(getMessageAction('edit')?.disabled).toBe(false); + expect(getMessageAction('rollback')?.disabled).toBe(false); }); it('disables edit and rollback while the Session is not idle', async () => { @@ -1070,8 +1104,9 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(true); - expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(true); + openMessageActionMenu(); + expect(getMessageAction('edit')?.disabled).toBe(true); + expect(getMessageAction('rollback')?.disabled).toBe(true); }); it('rolls back a cataloged history-window Turn by stable identity', async () => { @@ -1097,8 +1132,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__rollback-btn')?.click(); + getMessageAction('rollback')?.click(); await Promise.resolve(); await Promise.resolve(); }); @@ -1129,8 +1165,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__edit-btn')?.click(); + getMessageAction('edit')?.click(); }); await act(async () => { useMessageEditStore.getState().setDraft('edited older window prompt'); @@ -1195,8 +1232,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__edit-btn')?.click(); + getMessageAction('edit')?.click(); }); await act(async () => { useMessageEditStore.getState().setDraft('Describe this image in detail'); @@ -1262,8 +1300,9 @@ describe('UserMessageItem steering tag', () => { ); }); + openMessageActionMenu(); await act(async () => { - container.querySelector('.user-message-item__edit-btn')?.click(); + getMessageAction('edit')?.click(); }); await act(async () => { useMessageEditStore.getState().setDraft('Describe this more precisely'); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 69c0d3d610..6fb2b7692e 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -3,7 +3,7 @@ * Renders user input messages. */ -import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo, useId } from 'react'; import { withConversationExcerptFallback } from '@/shared/utils/conversationExcerpt'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { CircleAlert, RotateCcw, Loader2 } from 'lucide-react'; @@ -27,7 +27,7 @@ import { globalEventBus } from '@/infrastructure/event-bus'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; import { formatContextForPrompt } from '@/shared/utils/contextPrompt'; -import { Button, Dialog, DialogClose, Tooltip, Icon, IconButton } from '@openbitfun/ui'; +import { Button, Dialog, DialogClose, Tooltip, Icon, IconButton, MenuItem, MenuPopover, type MenuEntry, type MenuItemProps } from '@openbitfun/ui'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; import { ToolProcessingDots } from '@openbitfun/ui/flow-chat'; import { UserMessageEditComposer } from './UserMessageEditComposer'; @@ -68,6 +68,17 @@ import './UserMessageItem.scss'; const log = createLogger('UserMessageItem'); +const MessageActionReasonsContext = React.createContext>({}); +const messageActionMenuParts = { + item: React.forwardRef(function MessageActionMenuItem(props, ref) { + const reasons = React.useContext(MessageActionReasonsContext); + const actionId = (props as MenuItemProps & { 'data-menu-id'?: string })['data-menu-id']; + const reason = props.disabled ? reasons[actionId ?? ''] : undefined; + const item = ; + return reason ? {item} : item; + }), +}; + interface UserMessageItemProps { message: DialogTurn['userMessage']; turnId: string; @@ -124,6 +135,9 @@ export const UserMessageItem = React.memo( const [expanded, setExpanded] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); const [lightboxImage, setLightboxImage] = useState(null); + const [actionMenuOpen, setActionMenuOpen] = useState(false); + const actionMenuAnchorRef = useRef(null); + const actionMenuId = useId(); // Fine-grained selectors: only the message being edited re-renders on // draft keystrokes; other list items subscribe to booleans that rarely flip. const isEditing = useMessageEditStore(s => s.editingTurnId === turnId); @@ -247,6 +261,9 @@ export const UserMessageItem = React.memo( !steeringStatus && !hostQueueStatus; const canEdit = canEditBase && isSessionIdle && !isEditSubmitting && !sessionMutation; const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage && !hostQueueStatus; + const canShowRollbackMenuItem = !isFailed && canShowRollbackAction && !steeringStatus && !hostQueueStatus; + const canShowActionMenu = canShowEditAction || canShowRollbackMenuItem; + const rollbackPending = sessionMutation?.kind === 'rollback' && sessionMutation.targetTurnId === turnId; const editDisabledReason = isDispatchSession ? t('message.editDisabledDispatch') : isRemoteSession @@ -350,8 +367,7 @@ export const UserMessageItem = React.memo( } }, [copyText, copyTokens]); - const handleRollback = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); + const handleRollback = useCallback(async () => { if (!canRollback || !resolvedSessionId) return; const index = actionTurnIndex + 1; @@ -399,12 +415,28 @@ export const UserMessageItem = React.memo( } }, [actionTurnIndex, canRollback, composerPresentation, resolvedSessionId, restoredComposerContexts, t, turnId, messageContent]); - const handleBeginEdit = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); + const handleBeginEdit = useCallback(() => { if (!canEdit) return; beginEdit(turnId, messageContent); }, [beginEdit, canEdit, messageContent, turnId]); + const actionMenuItems: MenuEntry[] = [ + ...(canShowEditAction ? [{ + id: 'edit', + label: t('message.edit'), + icon: , + disabled: !canEdit, + onSelect: handleBeginEdit, + }] : []), + ...(canShowRollbackMenuItem ? [{ + id: 'rollback', + label: t('message.rollbackBeforeTurn'), + icon: , + disabled: !canRollback, + onSelect: () => { void handleRollback(); }, + }] : []), + ]; + const handleSubmitEdit = useCallback(async (submittedPresentation?: ComposerPresentation) => { if (!resolvedSessionId || actionTurnIndex < 0 || isEditSubmitting) return; @@ -745,7 +777,7 @@ export const UserMessageItem = React.memo( )} {!isEditing && ( -
+
( icon={copied ? : } /> - {canShowEditAction && ( - - } - /> - + {canShowActionMenu && ( + <> + + { + event.stopPropagation(); + setActionMenuOpen(open => !open); + }} + aria-label={t('message.editAndRollback')} + aria-haspopup="menu" + aria-expanded={actionMenuOpen} + aria-controls={actionMenuOpen ? actionMenuId : undefined} + icon={rollbackPending ? ( + + ) : } + /> + + + setActionMenuOpen(false)} + items={actionMenuItems} + parts={messageActionMenuParts} + inlineSize="content" + placement="bottom" + /> + + )} {isFailed ? ( @@ -776,20 +831,6 @@ export const UserMessageItem = React.memo( icon={} /> - ) : canShowRollbackAction && !steeringStatus && !hostQueueStatus ? ( - - - ) : ( - - )} - /> - ) : null}
)} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts index 0604c10664..a2c29075e4 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts @@ -53,7 +53,7 @@ describe('UserMessageItem metadata visibility', () => { expect(stylesheet).not.toContain('min-height: 5.5rem;'); }); - it('reveals the copy, edit, and rollback actions as one hover or focus cluster', () => { + it('reveals copy and the edit/rollback menu on hover, focus, or an open menu', () => { const stylesheet = readFileSync( fileURLToPath(new URL('./UserMessageItem.scss', import.meta.url)), 'utf8', @@ -66,14 +66,14 @@ describe('UserMessageItem metadata visibility', () => { expect(actions).not.toContain('visibility: hidden;'); expect(shell).not.toContain('.user-message-item__actions'); expect(stylesheet).toContain('.user-message-item-shell:hover .user-message-item__actions,'); + expect(stylesheet).toContain('.user-message-item__actions[data-menu-open],'); expect(stylesheet).toContain('.user-message-item-shell:focus-within .user-message-item__actions {\n opacity: 1;\n pointer-events: auto;'); expect(stylesheet).toContain([ '.user-message-item__copy-btn,', - '.user-message-item__edit-btn,', - '.user-message-item__rollback-btn {', + '.user-message-item__edit-rollback-btn {', ].join('\n')); - expect(stylesheet).not.toContain('.user-message-item__edit-btn {\n opacity: 1;'); + expect(stylesheet).not.toContain('.user-message-item__edit-rollback-btn {\n opacity: 1;'); }); it('reveals the timestamp beside the actions without shifting the action cluster', () => { diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 1b97db6a4e..1897a407f0 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -71,15 +71,6 @@ describe('isPeerLocalOnlyCommand', () => { expect(isPeerLocalOnlyCommand('i18n_set_config')).toBe(true); }); - it('keeps announcement scheduler and state on the controller device', () => { - expect(isPeerLocalOnlyCommand('get_pending_announcements')).toBe(true); - expect(isPeerLocalOnlyCommand('get_announcement_tips')).toBe(true); - expect(isPeerLocalOnlyCommand('mark_announcement_seen')).toBe(true); - expect(isPeerLocalOnlyCommand('dismiss_announcement')).toBe(true); - expect(isPeerLocalOnlyCommand('never_show_announcement')).toBe(true); - expect(isPeerLocalOnlyCommand('trigger_announcement')).toBe(true); - }); - it('keeps companion-pet import and preview on the controller device', () => { expect(isPeerLocalOnlyCommand('list_agent_companion_pets')).toBe(true); expect(isPeerLocalOnlyCommand('import_agent_companion_pet_package')).toBe(true); @@ -218,14 +209,6 @@ describe('peerInvokePriorityFor', () => { expect(isPeerRetryableReadCommand('respond_permission')).toBe(false); }); - it('does not retry side-effecting announcement get_* commands', () => { - // These run the scheduler (mutate app_open_count + persist) and must never - // be auto-retried by the peer read path, where retries would multiply the - // side effect. - expect(isPeerRetryableReadCommand('get_pending_announcements')).toBe(false); - expect(isPeerRetryableReadCommand('get_announcement_tips')).toBe(false); - }); - it('retries Browser Control / Computer Use status reads but not their mutations', () => { // The pure reads start with `browser_`/`computer_`, not `get_`/`read_`/ // `list_`, so they are listed explicitly; the side-effecting commands in diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 3f52c60f55..bac652c70e 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -148,22 +148,7 @@ export function isPeerLocalOnlyCommand(command: string): boolean { return LOCAL_ONLY_COMMANDS.has(command); } -/** - * `get_*` commands that run the announcement scheduler (mutate app_open_count + - * persist state). They are NOT side-effect-free reads and must never be - * auto-retried by the peer read path, where a retry would multiply the side - * effect. They are also LOCAL_ONLY, but this guard keeps the contract explicit - * if ownership ever moves back to the peer. See scheduler.rs run(). - */ -const SIDE_EFFECTING_GET_COMMANDS = new Set([ - 'get_pending_announcements', - 'get_announcement_tips', -]); - export function isPeerRetryableReadCommand(command: string): boolean { - if (SIDE_EFFECTING_GET_COMMANDS.has(command)) { - return false; - } return RETRYABLE_READ_COMMANDS.has(command) || command.startsWith('read_') || command.startsWith('list_') || diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index ec33769230..f7016e8b57 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; @@ -26,7 +26,7 @@ export interface ProductControlOptionIdsByCapability { "feature.pages": never; "feature.voice-input": never; "feature.desktop-pet": never; - "setting.application.general": "launch-at-login" | "prevent-sleep" | "auto-update" | "close-button-behavior" | "completion-notification" | "permission-notification" | "startup-tips"; + "setting.application.general": "launch-at-login" | "prevent-sleep" | "auto-update" | "close-button-behavior" | "completion-notification" | "permission-notification"; "setting.application.appearance": "theme" | "language"; "setting.application.pet": "enabled"; "setting.application.input": "auto-show-selection-toolbar" | "voice-enabled" | "voice-language" | "max-recording-seconds"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 457f29b74a..a7d3df7514 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:7a6e4c114b970186" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:fd9e646a3f645e19" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -52,7 +52,6 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "debug_devtools_available", "debug_open_devtools", "delete_agent_companion_pet_package", - "dismiss_announcement", "dispatch_answer", "dispatch_append", "dispatch_cancel", @@ -75,11 +74,9 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "frontend_update_candidate_failed", "frontend_update_candidate_ready", "generate_insights", - "get_announcement_tips", "get_frontend_update_status", "get_latest_insights", "get_local_models_dev_catalogs", - "get_pending_announcements", "get_pending_update", "get_prevent_sleep_enabled", "has_insights_data", @@ -99,12 +96,10 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "list_agent_companion_pets", "load_insights_report", "local_file_download", - "mark_announcement_seen", "mark_openbitfun_control_surface_ready", "mark_openbitfun_control_surface_unready", "market_image_load", "minimize_to_tray", - "never_show_announcement", "peer_control_attach", "peer_control_detach", "peer_controller_set_active", @@ -158,7 +153,6 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "speech_verify_model", "startup_window_control", "toggle_main_window_fullscreen", - "trigger_announcement", ]); /** Every capability id a peer host may advertise in `peer_mode_ping`. */ diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 6fe13b9347..3f58fc227d 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -51,7 +51,6 @@ import { fontPreferenceAppearanceDescriptor } from '@/infrastructure/font-prefer import { languageSelectorAppearanceDescriptor } from '@/infrastructure/i18n/appearance'; import { peerDeviceAppearanceDescriptor } from '@/infrastructure/peer-device/appearance'; import { updateAppearanceDescriptor } from '@/infrastructure/update/appearance'; -import { announcementAppearanceDescriptor } from '@/shared/announcement-system/appearance'; import { contextMenuAppearanceDescriptor } from '@/shared/context-menu-system/appearance'; import { contextListAppearanceDescriptor } from '@/shared/context-system/appearance'; import { notificationAppearanceDescriptor } from '@/shared/notification-system/appearance'; @@ -312,7 +311,6 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(sessionNavigationAppearanceDescriptor) .registerComponent(harnessProfileStepAppearanceDescriptor) .registerComponent(subagentAvatarAppearanceDescriptor) - .registerComponent(announcementAppearanceDescriptor) .registerComponent(contextMenuAppearanceDescriptor) .registerComponent(contextListAppearanceDescriptor) .registerComponent(notificationAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts index d195fd4b15..3d17ef4f85 100644 --- a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts +++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts @@ -51,9 +51,7 @@ describe('appearance overlay host', () => { expect(systemTokens.layer.overlayHost.$value).toBeGreaterThan(systemTokens.layer.overlay.$value); expect(readSource('src/shared/notification-system/components/NotificationContainer.tsx')).toContain(''); expect(readSource('src/shared/notification-system/components/NotificationContainer.tsx')).toContain(' (bottom-left) - * - (standard centre-screen presentation) - * - (release-letter presentation) - * - * ─── Debug Mode ─────────────────────────────────────────────────────────────── - * In development (`import.meta.env.DEV`), press **Ctrl+Shift+Alt+D** to - * inject all cards listed in `DEBUG_CARD_IDS` into the queue, bypassing - * backend filter logic. This lets you preview the full UI flow without - * clearing persisted state. - */ -const AnnouncementProvider: React.FC<{ ready: boolean }> = ({ ready }) => { - const { loadQueue, markInitialised, initialised, forceShowCards } = useAnnouncementStore(); - - // ── Normal load path ───────────────────────────────────────────────────── - useEffect(() => { - if (!ready || initialised) return; - let cancelled = false; - - const load = async () => { - try { - if (ENV_PREVIEW_CARD_ID) { - const previewCard = await announcementService.triggerCard(ENV_PREVIEW_CARD_ID); - if (cancelled) return; - if (!previewCard) { - log.warn('Announcement preview card was not found', { id: ENV_PREVIEW_CARD_ID }); - return; - } - log.debug('Force-showing announcement preview card', { id: previewCard.id }); - forceShowCards([previewCard]); - return; - } - - const cards = await announcementService.getPendingAnnouncements(); - if (cancelled) { - return; - } - const tipsEnabled = await configAPI.getConfig('app.notifications.enable_startup_tips') !== false; - if (cancelled) { - return; - } - const visibleCards = tipsEnabled ? cards : cards.filter((card) => card.card_type !== 'tip'); - if (visibleCards.length > 0) { - log.debug('Announcement cards loaded', { count: visibleCards.length }); - loadQueue(visibleCards); - } - } catch (e) { - log.error('Failed to load announcement cards', e); - } finally { - if (!cancelled) { - markInitialised(); - } - } - }; - - const cancelStartupSchedule = scheduleAfterStartupPaint(() => { - void load(); - }, { - frameCount: 2, - onError: error => { - log.error('Failed to schedule announcement load after startup', error); - }, - }); - - return () => { - cancelled = true; - cancelStartupSchedule(); - }; - }, [forceShowCards, initialised, loadQueue, markInitialised, ready]); - - // ── Debug trigger ───────────────────────────────────────────────────────── - const handleDebugTrigger = useCallback(async () => { - log.debug('[DEBUG] Triggering announcement preview', { ids: DEBUG_CARD_IDS }); - try { - const cards = await announcementService.debugTriggerCards(DEBUG_CARD_IDS); - if (cards.length === 0) { - log.warn('[DEBUG] No cards resolved for debug trigger. Check DEBUG_CARD_IDS.'); - return; - } - log.debug('[DEBUG] Force-showing cards', { count: cards.length, ids: cards.map((c) => c.id) }); - forceShowCards(cards); - } catch (e) { - log.error('[DEBUG] Failed to trigger debug cards', e); - } - }, [forceShowCards]); - - useEffect(() => { - if (!import.meta.env.DEV || !ready) return; - - const onKeyDown = (e: KeyboardEvent) => { - // Ctrl+Shift+Alt+D (all platforms) - if (e.ctrlKey && e.shiftKey && e.altKey && e.key === 'D') { - e.preventDefault(); - handleDebugTrigger(); - } - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [handleDebugTrigger, ready]); - - if (!ready) return null; - - return ( - <> - - - - - ); -}; - -export default AnnouncementProvider; diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx deleted file mode 100644 index 662a60c6ac..0000000000 --- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Button, Icon, IconButton, OverflowText, useHasModalOverlay } from '@openbitfun/ui'; -import type { AnnouncementCard } from '../types'; -import { useAnnouncementStore } from '../store/announcementStore'; -import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n'; - -interface Props { - card: AnnouncementCard; -} - -const ANNOUNCEMENT_TOAST_EXIT_MS = 220; - -/** - * Bottom-left toast: compact fixed-width card. - * Layout (top → bottom): title row (+ close) → description → action buttons. - */ -const AnnouncementToastItem: React.FC = ({ card }) => { - const { t } = useAnnouncementI18n(); - const { openModalFor, dismissToast } = useAnnouncementStore(); - const [exiting, setExiting] = useState(false); - const modalOpen = useHasModalOverlay(); - const autoDismissTimer = useRef | null>(null); - const exitTimer = useRef | null>(null); - - const { toast, card_type, modal } = card; - const hasModal = card_type !== 'tip' && modal !== null; - const autoDismissMs = toast.auto_dismiss_ms; - const remainingMs = useRef(autoDismissMs ?? 0); - - const resolve = (key: string) => (key.startsWith('announcements.') ? t(key) : key); - - function triggerExit(callback: () => void) { - if (exiting) return; - if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current); - setExiting(true); - exitTimer.current = setTimeout(callback, ANNOUNCEMENT_TOAST_EXIT_MS); - } - - function handleDismiss() { - triggerExit(() => dismissToast(card)); - } - - function handleAction() { - if (hasModal) { - triggerExit(() => openModalFor(card)); - } else { - handleDismiss(); - } - } - - useEffect(() => { - if (exiting || modalOpen || remainingMs.current <= 0) return; - const startedAt = Date.now(); - autoDismissTimer.current = setTimeout(handleDismiss, remainingMs.current); - return () => { - if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current); - remainingMs.current = Math.max(0, remainingMs.current - (Date.now() - startedAt)); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [card.id, exiting, modalOpen]); - - useEffect(() => () => { - if (exitTimer.current) clearTimeout(exitTimer.current); - }, []); - - const actionLabel = - resolve(toast.action_label) || - (hasModal ? t('announcements.common.learn_more') : t('announcements.common.got_it')); - - return ( -
- {/* Row 1: title + close with optional countdown ring */} -
- {resolve(toast.title)} - {toast.dismissible && ( -
- {autoDismissMs != null && autoDismissMs > 0 && ( - - )} - } - onClick={handleDismiss} - aria-label={t('announcements.common.close')} - /> -
- )} -
- - {/* Row 2: description */} - {resolve(toast.description)} - - {/* Row 3: action buttons */} -
- -
- -
- ); -}; - -export default AnnouncementToastItem; diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx deleted file mode 100644 index 478f1a2bce..0000000000 --- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { Portal } from '@openbitfun/ui'; -import { useAnnouncementStore } from '../store/announcementStore'; -import AnnouncementToastItem from './AnnouncementToastItem'; -import '../styles/AnnouncementToast.scss'; - -/** - * Fixed bottom-left announcement stack. - * - * Shows the active toast on top. When more cards are queued, up to two ghost - * layers peek out beneath it to hint that more notifications are waiting — - * no text badge is shown. - */ -const AnnouncementToastStack: React.FC = () => { - const { activeToast, toastVisible, queue } = useAnnouncementStore(); - - if (!activeToast || !toastVisible) return null; - - const ghostCount = Math.min(queue.length, 2); - - return ( - -
-
- {/* Ghost layers: rendered before active card = lower in DOM = behind */} - {ghostCount >= 2 && ( -
- )} - {ghostCount >= 1 && ( -
- )} - -
-
- - ); -}; - -export default AnnouncementToastStack; diff --git a/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx b/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx deleted file mode 100644 index 47194325c3..0000000000 --- a/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { Button, Icon, IconButton, Portal, ScrollArea } from '@openbitfun/ui'; -import { useAnnouncementStore } from '../store/announcementStore'; -import FeatureModalPage from './FeatureModalPage'; -import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n'; -import '../styles/FeatureModal.scss'; -import type { ModalConfig } from '../types'; - -const FEATURE_MODAL_EXIT_MS = 180; -const FEATURE_MODAL_PAGE_MS = 180; - -interface FeatureModalPagesProps { - pages: NonNullable; - currentPage: number; -} - -const FeatureModalPages: React.FC = ({ pages, currentPage }) => { - const [displayedPage, setDisplayedPage] = useState(currentPage); - const [leavingPage, setLeavingPage] = useState(null); - const [direction, setDirection] = useState<'forward' | 'backward'>('forward'); - const settleTimerRef = useRef(null); - - useLayoutEffect(() => { - if (currentPage === displayedPage) return; - if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); - setDirection(currentPage > displayedPage ? 'forward' : 'backward'); - setLeavingPage(displayedPage); - setDisplayedPage(currentPage); - settleTimerRef.current = window.setTimeout(() => { - settleTimerRef.current = null; - setLeavingPage(null); - }, FEATURE_MODAL_PAGE_MS); - }, [currentPage, displayedPage]); - - useEffect(() => () => { - if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); - }, []); - - const renderPage = (pageIndex: number, state: 'active' | 'leaving') => { - const page = pages[pageIndex]; - if (!page) return null; - return ( - - - - ); - }; - - return ( - <> - {leavingPage !== null && leavingPage !== displayedPage - ? renderPage(leavingPage, 'leaving') - : null} - {renderPage(displayedPage, 'active')} - - ); -}; - -/** - * Centre-screen feature demo modal. - * - * - Multi-page with dot-indicator navigation. - * - Each page can carry a media asset (Lottie, video, image, GIF). - * - Backdrop click closes the modal when `closable` is true. - * - Supports a "Don't show again" completion action. - */ -const FeatureModal: React.FC = () => { - const { t } = useAnnouncementI18n(); - const { - openModal, - modalVisible, - currentPage, - setPage, - closeModal, - markModalPresented, - } = useAnnouncementStore(); - - const [exiting, setExiting] = React.useState(false); - const backdropRef = useRef(null); - const closeTimerRef = useRef(null); - - // When modalVisible becomes false trigger the exit animation. - useEffect(() => { - if (!modalVisible) setExiting(false); - }, [modalVisible]); - - useEffect(() => () => { - if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current); - }, []); - - useEffect(() => { - if ( - openModal - && modalVisible - && openModal.modal?.presentation !== 'release_letter' - && backdropRef.current - ) { - markModalPresented(openModal); - } - }, [markModalPresented, modalVisible, openModal]); - - if ( - !openModal - || !modalVisible - || openModal.modal?.presentation === 'release_letter' - ) return null; - - const modal: ModalConfig = openModal.modal!; - const pages = modal.pages ?? []; - const isFirst = currentPage === 0; - const isLast = currentPage === pages.length - 1; - - function triggerClose(neverShow = false) { - if (exiting) return; - setExiting(true); - closeTimerRef.current = window.setTimeout(() => { - closeTimerRef.current = null; - closeModal(neverShow); - }, FEATURE_MODAL_EXIT_MS); - } - - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === backdropRef.current && modal.closable) { - triggerClose(); - } - } - - const sizeClass = `feature-modal--${modal.size ?? 'lg'}`; - - return ( - triggerClose()} dismissOnEscape={Boolean(modal.closable)}> -
-
- {/* Close button */} - {modal.closable && ( - } - size="md" - data-openbitfun-component="announcement" - data-openbitfun-part="modalClose" - onClick={() => triggerClose()} - aria-label={t('announcements.common.close')} - /> - )} - - {/* Page viewport */} -
- -
- - {/* Footer navigation */} -
- {/* Dot indicators */} -
- {pages.map((_, i) => ( -
- - {/* Navigation buttons */} -
- {modal.completion_action === 'never_show_again' && isLast && ( - - )} - {!isFirst && ( - - )} - {!isLast ? ( - - ) : ( - - )} -
-
-
-
-
- ); -}; - -export default FeatureModal; diff --git a/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx b/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx deleted file mode 100644 index 88fbce0070..0000000000 --- a/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react'; -import type { ModalPage } from '../types'; -import MediaRenderer from './MediaRenderer'; -import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n'; - -interface Props { - page: ModalPage; - active: boolean; -} - -/** - * Renders a single page inside the FeatureModal. - * - * Handles i18n key resolution and Markdown body rendering (simple HTML via - * dangerouslySetInnerHTML – body content is authored internally so this is safe). - */ -const FeatureModalPage: React.FC = ({ page, active }) => { - const { t } = useAnnouncementI18n(); - - const resolve = (key: string) => (key.startsWith('announcements.') ? t(key) : key); - - /** - * Lightweight Markdown → HTML for announcement body content. - * - * Supported syntax: - * - **bold** - * - `inline code` - * - Pipe tables (|---|) - * - Blank-line paragraph breaks - * - Single line-break →
within a paragraph - */ - function renderBody(raw: string): string { - const resolved = resolve(raw); - - // Split into blocks on double newline. - const blocks = resolved.split(/\n{2,}/); - - const htmlBlocks = blocks.map((block) => { - const trimmed = block.trim(); - - // ── Table detection: block must have at least 3 lines where one is a separator row ── - const lines = trimmed.split('\n'); - const isSeparatorRow = (l: string) => /^\|[\s|:-]+\|$/.test(l.trim()); - if (lines.length >= 2 && lines.some(isSeparatorRow)) { - const rows = lines.filter((l) => !isSeparatorRow(l)); - const toTd = (l: string, tag: 'th' | 'td') => - l - .split('|') - .filter((_, i, a) => i > 0 && i < a.length - 1) // drop leading/trailing empty - .map((cell) => `<${tag}>${inlineMarkdown(cell.trim())}`) - .join(''); - - const [header, ...body] = rows; - return [ - '', - `${toTd(header, 'th')}`, - `${body.map((r) => `${toTd(r, 'td')}`).join('')}`, - '
', - ].join(''); - } - - // ── Regular paragraph ── - const inner = lines - .map((l) => inlineMarkdown(l)) - .join('
'); - return `

${inner}

`; - }); - - return htmlBlocks.join('\n'); - } - - /** Apply inline Markdown (bold, code) to a single line. */ - function inlineMarkdown(line: string): string { - return line - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`([^`]+)`/g, '$1'); - } - - const layoutClass = `feature-modal-page--${page.layout}`; - - return ( -
- {page.media && ( -
- -
- )} -
-
-

{resolve(page.title)}

-
-
-
-
- ); -}; - -export default FeatureModalPage; diff --git a/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx b/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx deleted file mode 100644 index 62096b19b8..0000000000 --- a/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import type { MediaConfig } from '../types'; - -interface Props { - media: MediaConfig; - /** Whether this page is currently visible (affects autoplay). */ - active: boolean; -} - -interface LottieProps { - src: string; - active: boolean; -} - -const LottieRenderer: React.FC = ({ src, active }) => { - const [LottieComponent, setLottieComponent] = - React.useState | null>(null); - const [loadError, setLoadError] = React.useState(false); - - useEffect(() => { - let cancelled = false; - const pkg = '@lottiefiles/dotlottie-react'; - import(/* @vite-ignore */ pkg) - .then((mod: any) => { - if (!cancelled) setLottieComponent(() => mod.DotLottieReact ?? mod.default); - }) - .catch(() => { - if (!cancelled) setLoadError(true); - }); - return () => { cancelled = true; }; - }, []); - - if (loadError) { - return ( -
-
- Lottie library not available -
-
- ); - } - - if (!LottieComponent) { - return ( -
-
Loading…
-
- ); - } - - return ( -
- -
- ); -}; - -/** - * Renders a media asset inside a modal page. - * - * Supported types: - * - `image` / `gif` → - * - `video` →