From 59fb4560be7193075d0cac95489950054971b470 Mon Sep 17 00:00:00 2001 From: sunlight798 <3281498087@qq.com> Date: Wed, 21 Oct 2026 22:38:39 +0800 Subject: [PATCH 01/12] feat(desktop): add shared layout and navigation Refs #2102. --- desktop/README.md | 22 +- desktop/README.zh.md | 22 +- desktop/VALIDATION.md | 18 + desktop/tests/installed_lifecycle.py | 4 +- desktop/tests/installed_ui.py | 4 +- desktop/tests/installed_workflow.py | 2 +- desktop/ui/src/app/App.tsx | 451 ++++++++++++--------- desktop/ui/src/app/Connections.tsx | 21 +- desktop/ui/src/app/Observation.tsx | 88 +++++ desktop/ui/src/app/Overview.tsx | 230 ----------- desktop/ui/src/app/PageFeedback.tsx | 42 ++ desktop/ui/src/app/SettingsHelp.tsx | 133 +++++++ desktop/ui/src/app/TopBar.tsx | 273 +++++++++---- desktop/ui/src/app/messages.ts | 86 +++- desktop/ui/src/app/navigation.ts | 24 ++ desktop/ui/src/app/style.css | 87 +++- desktop/ui/tests/app.test.tsx | 549 +++++++++++++++++++++++++- desktop/ui/tests/connections.test.tsx | 6 +- desktop/ui/tests/memory.test.tsx | 16 +- desktop/ui/tests/scopes.test.tsx | 1 + 20 files changed, 1528 insertions(+), 551 deletions(-) create mode 100644 desktop/ui/src/app/Observation.tsx delete mode 100644 desktop/ui/src/app/Overview.tsx create mode 100644 desktop/ui/src/app/PageFeedback.tsx create mode 100644 desktop/ui/src/app/SettingsHelp.tsx create mode 100644 desktop/ui/src/app/navigation.ts diff --git a/desktop/README.md b/desktop/README.md index 102c27bc04..c356ac4859 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -1,9 +1,27 @@ # PowerContext Desktop preview -Internal Windows preview for [#1654](https://github.com/oceanbase/powercontext/issues/1654), following RFC #1455. It provides a packaged Tauri 2 shell with Home, Connections, My memories, Chinese/English settings, semantic light/dark/system themes and honest disconnected states. Connection profiles, explicit activation, identity/readiness checks, exact Scope selection and local diagnostics are implemented. Shared note saving, bounded FTS search and exact-version reading are implemented; native workflow qualification remains open. +Internal Windows preview for [#1654](https://github.com/oceanbase/powercontext/issues/1654), following RFC #1455. Its shared application layout provides Memory, Connections, Observation, and Settings & help, Chinese/English interfaces, and light/dark/system themes. Connection profiles, explicit activation, identity/readiness checks, exact Scope selection, local diagnostics, note saving, bounded FTS search and exact-version reading are implemented; native workflow qualification remains open. See [中文说明](README.zh.md), [security boundary](SECURITY.md), and [validation guide](VALIDATION.md). This is not a supported or signed release, and does not close #1654 or #1428. +## Navigation and first use + +Memory is the initial page. After Desktop state loads, an inactive connection offers **Set up locally** and **Connect an existing server**. Local setup opens **Settings & help → Local Server**, which explains that installation and service management are not available yet. Prepare a Server independently using the instructions below, or obtain its address and applicable credentials from an administrator. The global connection menu opens the selected configuration without activating it. Explicitly activate a connection, select an exact Scope, and use the existing Memory workspace. + +The global context displays the connection, endpoint, exact Scope title and ID, identity, compatibility and last observed health facts on every page. Health does not grant operation permissions. Changing pages or connection targets requires confirmation when input is unsaved. Closing Scope selection leaves the current draft intact. Submitted writes retain their original target and are not automatically replayed. + +Observation contains **Runtime state**, **Current-Scope inventory & usage**, and **This session**. These are unavailable placeholders until their owning features are integrated; no sample data, invented zero-valued statistics or automatic readiness probes are shown. Connections retains its explicit connection checks, whose native readiness behavior is tracked separately by #2101. + +Settings & help remains available without HTTP and provides language, theme, Local Server guidance, explicit local CLI diagnostics, the observed Desktop version, and copyable usage/support links. An unavailable native version is shown as unknown. Local diagnostic failure does not disable independently available remote operations. + +Use Tab to reach navigation and controls. The connection menu supports arrow keys, Home/End and Escape; Observation tabs support Left/Right and Home/End. Scope selection contains keyboard focus and restores it to the opening control when closed. Small windows use collapsible navigation; context and dialog contents can scroll without hiding recovery controls. + +### UI integration interfaces + +`ui/src/app/navigation.ts` defines `Page`, `ObservationTab`, `Theme`, and `NavigationTarget`. Application navigation accepts an optional connection ID, Observation tab, or `local-server` settings section. External connection selections pass the application's draft guard before reaching the editor. The editor's `selection` request invalidates pending editor completions, so a late save cannot replace a different selected configuration. + +`Observation` accepts independent `panels` keyed by `runtime`, `inventory`, and `session`, with unavailable defaults. Each owning page provides data, authorization, refresh and cancellation behavior. `PageFeedback` presents loading/error/empty/unavailable states with caller-owned explanations and actions. Local setup and management belong to #2117; observation content belongs to #2105, #2106 and #2107. These interfaces do not extend native IPC or the public HTTP contract. + ## Run the installed preview Download and extract `desktop-windows-internal-unsigned` from a successful **Desktop validation** Actions run on this branch. The installer is under `src-tauri/target/release/bundle/nsis/`. Its accompanying `.artifacts/windows-smoke.json` records the commit, SHA-256 and signature status; compare the downloaded installer with `Get-FileHash -Algorithm SHA256 `. The current package is unsigned and intended for internal validation. @@ -59,7 +77,7 @@ Profiles persist under the app data directory; credentials never appear in profi ## Save, find and read a note -After explicitly activating a qualified connection and choosing a Scope, use Home's note form or **My memories → Add note**. The target connection and exact Scope are shown before submission. Enter inserts a newline; only the save button submits. The input is plain text, limited conservatively to 8192 raw UTF-8 bytes without truncation. The Server owns normalization and the returned text is authoritative. +After explicitly activating a qualified connection and choosing a Scope, open **Memories** and enter a note in the save form. The target connection and exact Scope are shown before submission. Enter inserts a newline; only the save button submits. The input is plain text, limited conservatively to 8192 raw UTF-8 bytes without truncation. The Server owns normalization and the returned text is authoritative. Search uses FTS in the selected Scope and returns at most 10 matches. This is not a full directory or history, and ten matches do not establish a total count. **Read exact version** reads the returned Atomic Memory reference (`{family: "atomic-memory", artifact_id, revision}`) at that exact revision; it never substitutes the latest version. Copy buttons explicitly copy either the full plain text or the reference JSON. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 62f7bc952c..90665fd4df 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -1,9 +1,27 @@ # PowerContext Desktop 预览版 -这是 #1654 的内部工程预览。已建立 Tauri 2、React、TypeScript 和 Vite 独立工程,提供首页、连接、我的记忆、双语设置及浅色/深色/系统主题。无连接时保存和搜索不可用,不展示示例业务数据。 +这是 #1654 的内部工程预览。已建立 Tauri 2、React、TypeScript 和 Vite 独立工程,提供记忆、连接、观察、设置与帮助四个入口,以及中英文界面和浅色/深色/系统主题。无连接时保存和搜索不可用,不展示示例业务数据。 已实现连接配置、显式激活、身份与就绪检查、精确范围选择和本地诊断。已实现共享笔记表单、FTS 搜索和精确版本阅读;原生交互闭环仍待完整验收。当前不是已签名的正式发行版。 +## 导航与首次使用 + +默认进入“记忆”。Desktop 状态加载完成且没有活动连接时,提供“本地设置”和“连接已有服务”两条路径。“本地设置”进入“设置与帮助 → 本地 Server”,明确说明安装和服务管理尚未可用。可以按下文指引独立准备 Server,或向管理员获取地址和适用凭据。从全局连接菜单选择已保存配置只会打开对应编辑器,不会激活连接。显式激活连接并选择精确 Scope 后,使用现有记忆工作区。 + +全局上下文在各页面展示连接、地址、精确 Scope 名称和 ID、身份、兼容性及上次观察到的健康事实。健康状态不代表操作权限。有未保存输入时,切换页面或连接配置需要确认;关闭 Scope 选择不会丢弃当前草稿。已提交写入始终绑定原目标,不会自动重放。 + +“观察”包含“运行状态”“当前 Scope 库存与用量”“本次会话”三个标签页。在各功能接入前,明确显示尚未可用,不展示示例数据、虚构的零值统计,也不自动探测 readiness。“连接”保留现有显式连接检查,其原生 readiness 行为由 #2101 单独处理。 + +“设置与帮助”无需 HTTP 连接即可访问,包含语言、主题、本地 Server 指引、显式执行的本机 CLI 诊断、实际获取的 Desktop 版本,以及可复制的使用指南和反馈地址。无法获取原生版本时显示“未知”;本机诊断失败不会阻断独立可用的远程操作。 + +使用 Tab 到达导航与控件;连接菜单支持方向键、Home/End 和 Escape;观察标签页支持左右方向键和 Home/End。Scope 弹窗约束键盘焦点,关闭后返回打开它的控件。小窗口使用可折叠导航,上下文和弹窗内容可以滚动,不遮挡恢复操作。 + +### UI 接入接口 + +`ui/src/app/navigation.ts` 定义 `Page`、`ObservationTab`、`Theme` 和 `NavigationTarget`。应用导航支持指定连接 ID、观察标签页或 `local-server` 设置区域。外部连接选择必须先经过应用层草稿确认,再传给连接编辑器。编辑器的 `selection` 请求会使旧编辑操作的完成回调失效,避免迟到保存替换新选中的配置。 + +`Observation` 接受按 `runtime`、`inventory`、`session` 分别提供的 `panels`,默认展示尚未可用;数据、权限、刷新和取消行为由各页面实现。`PageFeedback` 统一加载、错误、空状态和不可用状态的呈现,解释和操作由调用方提供。本地安装与管理归属 #2117,观察内容归属 #2105、#2106、#2107;这些接口不扩展原生 IPC 或公共 HTTP 合同。 + ## 直接运行安装版 在本分支的 GitHub Actions「Desktop validation」成功运行中,下载 `desktop-windows-internal-unsigned` 产物并解压,找到 `src-tauri/target/release/bundle/nsis/` 中的安装程序。对应 `.artifacts/windows-smoke.json` 记录提交、安装包 SHA-256 和签名状态,可用 `Get-FileHash -Algorithm SHA256 <安装包路径>` 核对下载文件。当前产物未签名,供内部验证。 @@ -78,7 +96,7 @@ Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service ## 保存、查找和阅读 -明确启用合格连接并选择精确范围后,在首页填写笔记,或在「我的记忆」中点击「记一条」。提交前会显示目标连接和范围。Enter 只换行,点击保存才提交。正文是纯文本,保守限制为原始文本最多 8192 个 UTF-8 字节,不会截断;规范化由 Server 完成,以返回的正文为准。 +明确启用合格连接并选择精确范围后,打开「记忆」,在保存表单中填写笔记。提交前会显示目标连接和范围。Enter 只换行,点击保存才提交。正文是纯文本,保守限制为原始文本最多 8192 个 UTF-8 字节,不会截断;规范化由 Server 完成,以返回的正文为准。 全文搜索只在当前范围执行,每次最多返回 10 条,不代表完整目录、历史或总数。阅读按返回的 Atomic Memory 引用(`{family: "atomic-memory", artifact_id, revision}`)读取该精确版本,不自动改读最新版本。复制按钮分别复制纯文本正文和引用 JSON。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 39c31eac20..bcf0a036f0 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -61,6 +61,24 @@ uv run python desktop/tests/fixture_qualification.py --server-commit "$DESKTOP_F pnpm --dir desktop tauri build --bundles nsis --ci --features ci-fixtures -- --locked ``` +## Shared application layout and navigation (#2102) + +Local verification on 2026-10-21 used Windows x64, Node 24.14.1, pnpm 11.13.1, and Rust 1.95.0 for generated-contract checking: + +- `pnpm --dir desktop test`: 48 UI behavior tests passed. These use mocked IPC and cover navigation, exact-Scope context, draft guards, late completions, keyboard/focus behavior, and existing Memory/connection/diagnostic journeys. +- `pnpm --dir desktop typecheck`, `lint`, and `build` passed. Lint includes OpenAPI drift, canonical brand-resource, and formatting checks; generated contracts were checked without updating them. +- Ruff 0.15.7 lint and formatting checks passed for the changed installed-acceptance Python scripts. Their startup/navigation selectors were updated; the installed harness itself was not executed locally. +- Headless Microsoft Edge 155.0.4283.62 exercised four pages and three Observation tabs across Chinese/English, light/dark/system, and CSS viewports 1180×800, 800×600, 400×400, and 400×300. The 24 combinations passed horizontal-overflow, Scope dialog focus/return, connection-menu, and local diagnostic error checks using synthetic IPC. The 400×300 viewport with device scale factor 2 checks layout equivalent to 800×600 at 200%; it does not establish actual WebView zoom behavior. +- Additional browser scenarios exercised draft-confirmation dismissal/acceptance at all four viewports, and disconnected first use, Local Server placement and initialization failure in both languages at 800×600, 400×400 and 400×300. Drafts survived canceled navigation, and first-use/error pages remained free of horizontal overflow. + +Browser checks and mocked IPC do not establish Server compatibility, installed-package behavior, actual IME, screen-reader output, native confirmation presentation, or macOS qualification. Those remain platform acceptance work under #2108/#2109. Local installation and service management remain unavailable pending #2117; observation content remains unavailable pending #2105/#2106/#2107. + +2026-10-21 的本地验证使用 Windows x64、Node 24.14.1、pnpm 11.13.1,生成合同检查使用 Rust 1.95.0。48 项 UI 行为测试及类型、lint、生产构建检查通过;UI 测试使用模拟 IPC,覆盖导航、精确 Scope、草稿保护、迟到响应、键盘与焦点,以及现有业务流程。修改的安装验收 Python 脚本通过 Ruff 0.15.7 检查,本地未执行安装验收脚本。 + +无头 Edge 155.0.4283.62 使用合成 IPC,检查中英文、三种主题及上述四组 CSS 视口,共 24 种组合,覆盖四个页面、三个观察标签、横向溢出、Scope 弹窗焦点、连接菜单和本机诊断错误。400×300 配合设备缩放因子 2 仅验证 800×600 在 200% 下的等效布局,不代表实际 WebView 缩放验收。真实 Server、安装包、IME、屏幕阅读器、原生确认框呈现和 macOS 仍需对应平台验收;本地安装和观察数据功能仍明确标注尚未可用。 + +额外浏览器场景验证了四组视口中的草稿确认取消/接受,以及中英文在 800×600、400×400、400×300 下的未连接首次使用、本地 Server 入口和初始化失败状态。取消导航保留草稿,首次使用及错误页面没有横向溢出。 + ## Remaining release qualification - Clean standard-user Windows 11, including absent/present WebView2 and bootstrap recovery. diff --git a/desktop/tests/installed_lifecycle.py b/desktop/tests/installed_lifecycle.py index 7d58880437..f856ac6f05 100644 --- a/desktop/tests/installed_lifecycle.py +++ b/desktop/tests/installed_lifecycle.py @@ -91,8 +91,8 @@ def exercise_single_instance(client: httpx.Client, prefix: str, app: subprocess. != "single-instance unsaved draft" ): raise HarnessFailure("installed_second_launch_changed_draft") - page.button("总览") + page.button("观察") page.post("/alert/dismiss", {}) - page.button("总览") + page.button("观察") page.post("/alert/accept", {}) return {"secondLaunchesExited": 3, "originalEditorAndDraftPreserved": True} diff --git a/desktop/tests/installed_ui.py b/desktop/tests/installed_ui.py index 151db1f886..27a47d95ac 100644 --- a/desktop/tests/installed_ui.py +++ b/desktop/tests/installed_ui.py @@ -86,12 +86,12 @@ def wait_packaged_page(client: httpx.Client, prefix: str) -> str: ) response.raise_for_status() page = response.json()["value"] - if "总览" in page["text"]: + if all(label in page["text"] for label in ("记忆", "连接", "观察", "设置与帮助")): if not page["url"].startswith("http://tauri.localhost"): raise HarnessFailure("installed_resources_not_packaged") return page["url"] time.sleep(0.2) - raise HarnessFailure("installed_overview_not_rendered") + raise HarnessFailure("installed_shell_not_rendered") def screenshot(client: httpx.Client, prefix: str, artifacts: Path) -> bool: diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index d0e793fdfa..e2b24b01d3 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -370,7 +370,7 @@ def exercise_late_save(page: InstalledPage) -> None: gate.unlink() # This is shown only after the old form refreshes the native write record. page.wait_text("连接或范围已改变,旧正文已隐藏。") # noqa: RUF001 - exact localized UI - page.button("总览") + page.button("观察") page.post("/alert/dismiss", {}) if page.observe("return document.querySelector('.memory-workspace textarea')?.value;") != draft: raise HarnessFailure("installed_late_save_lost_new_draft") diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 6fc3e89e4a..7a15d15506 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -14,54 +14,73 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { messages, type Language } from "./messages"; import { getFoundationInfo, desktopApi } from "../shared/ipc"; import type { FoundationInfo, DesktopState } from "../generated/ipc"; import logo from "../../../src-tauri/icons/brand.png"; -import homeIcon from "../assets/overview.svg"; +import observationIcon from "../assets/overview.svg"; import connectionsIcon from "../assets/connections.svg"; import memoryIcon from "../assets/memory.svg"; import settingsIcon from "../assets/settings.svg"; - import { TopBar } from "./TopBar"; -import { Overview } from "./Overview"; import { MemoryWorkspace } from "./MemoryWorkspace"; -import { Diagnostics } from "./Diagnostics"; import { Connections } from "./Connections"; import { Scopes } from "./Scopes"; +import { Observation } from "./Observation"; +import { SettingsHelp } from "./SettingsHelp"; +import { PageFeedback } from "./PageFeedback"; +import type { + NavigationTarget, + ObservationTab, + Page, + Theme, +} from "./navigation"; import { connectionMessages, connectionError } from "./connection-messages"; -type Page = "home" | "connections" | "memories" | "settings"; -type Theme = "light" | "dark" | "system"; export function App() { const [language, setLanguage] = useState("zh"); const [theme, setTheme] = useState("system"); - const [page, setPage] = useState("home"); + const [page, setPage] = useState("memories"); + const [tab, setTab] = useState("runtime"); + const [connectionSelection, setConnectionSelection] = useState({ + id: null as string | null, + request: 0, + }); + const [localServerRequest, setLocalServerRequest] = useState(0); const [menu, setMenu] = useState(false); const [host, setHost] = useState(null); const [desktop, setDesktop] = useState(null); const [nativeError, setNativeError] = useState(null); + const [loading, setLoading] = useState(true); const [dirty, setDirty] = useState(false); const [scopeOpen, setScopeOpen] = useState(false); - const [refreshing, setRefreshing] = useState(false); + const heading = useRef(null); + const scopeDialog = useRef(null); + const scopeReturnFocus = useRef(null); + const t = messages[language]; + const ct = connectionMessages[language]; function receiveState(next: DesktopState) { setDesktop((current) => !current || next.generation >= current.generation ? next : current, ); } function confirmSwitch() { - return !dirty || window.confirm(connectionMessages[language].discard); + return !dirty || window.confirm(ct.discard); + } + async function loadState() { + setLoading(true); + setNativeError(null); + try { + receiveState(await desktopApi.state()); + } catch (error) { + setNativeError(error); + } finally { + setLoading(false); + } } - const activeProfile = desktop?.profiles.find( - (p) => p.id === desktop.active?.connectionId, - ); - const activeReport = desktop?.active?.report; - const heading = useRef(null); - const t = messages[language]; - const ct = connectionMessages[language]; useEffect(() => { - desktopApi.state().then(receiveState).catch(setNativeError); + void loadState(); getFoundationInfo() .then(setHost) .catch(() => setHost(null)); @@ -72,200 +91,253 @@ export function App() { useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); + useLayoutEffect(() => { + heading.current?.focus(); + }, [page]); useEffect(() => { if (!scopeOpen) return; + const previous = scopeReturnFocus.current; + const dialog = scopeDialog.current; + const background = document.querySelector(".app-background"); + if (background) background.inert = true; + dialog?.querySelector("button")?.focus(); function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") setScopeOpen(false); + if (event.key === "Escape") { + event.preventDefault(); + setScopeOpen(false); + } + if (event.key !== "Tab") return; + const elements = Array.from( + dialog?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]', + ) ?? [], + ).filter((element) => !element.matches(":disabled")); + const first = elements[0]; + const last = elements[elements.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } } document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + if (background) background.inert = false; + if (previous?.isConnected) previous.focus(); + else + document + .querySelector(".topbar-group button") + ?.focus(); + }; }, [scopeOpen]); - function navigate(next: Page) { - if (next === page || !confirmSwitch()) return; + function navigate(target: NavigationTarget) { + const samePage = target.page === page; + if (samePage && !(target.page === "connections" && target.connectionId)) { + if (target.page === "observation" && target.tab) setTab(target.tab); + if (target.page === "settings" && target.section) + setLocalServerRequest((value) => value + 1); + setMenu(false); + if (!(target.page === "settings" && target.section)) + heading.current?.focus(); + return; + } + if (!confirmSwitch()) return; setDirty(false); - setPage(next); + if (target.page === "connections") + setConnectionSelection((current) => ({ + id: target.connectionId ?? null, + request: current.request + 1, + })); + if (target.page === "observation" && target.tab) setTab(target.tab); + if (target.page === "settings") + setLocalServerRequest((value) => (target.section ? value + 1 : 0)); + setPage(target.page); setMenu(false); - requestAnimationFrame(() => heading.current?.focus()); + if (samePage) heading.current?.focus(); } function disconnect() { if (!confirmSwitch()) return; - setDirty(false); void desktopApi.disconnect().then(receiveState).catch(setNativeError); } - async function refresh() { - setRefreshing(true); - setNativeError(null); - try { - receiveState( - desktop?.active - ? await desktopApi.check(desktop.active.connectionId, false) - : await desktopApi.state(), - ); - } catch (e) { - setNativeError(e); - } finally { - setRefreshing(false); - } - } - const ready = activeReport?.readiness.value?.status === "ready"; + const activeProfile = desktop?.profiles.find( + (profile) => profile.id === desktop.active?.connectionId, + ); return ( -
- - {t.skip} - -
- PowerContext - -
- -
- navigate("connections")} - onOpenScopes={() => setScopeOpen(true)} - onDisconnect={disconnect} - /> -
-

- {t[page]} -

- {nativeError != null && ( -

{connectionError(nativeError, language)}

- )} - {page === "memories" && desktop?.active && ( -
- {activeProfile?.name} - - {activeProfile?.endpoint} - - {desktop.active.scope?.title ?? t.noScopeSelected} - - {ready ? t.serviceReady : t.unverified} - -
- )} - {page === "home" && ( - void refresh()} - /> - )} - {page === "memories" && ( - - )} - {page === "connections" && ( - - )} - {page === "settings" && ( -
-
-
- - -
-
- - -
-
- -
-

{t.version}

-
-
-
{t.version}
-
{host?.version ?? "0.1.0"}
-
-
-
{t.native}
-
- {host ? t.nativeReady : t.unavailable} -
-
-
-

{t.boundary}

-
-
- )} -
+

{t.selectScopeDescription}

+ + )} + +
+ )} + {page === "connections" && ( + + )} + {page === "observation" && ( + + )} + {page === "settings" && ( + + )} + +
{scopeOpen && (

{ct.scope}

diff --git a/desktop/ui/src/app/Connections.tsx b/desktop/ui/src/app/Connections.tsx index c691e2dad2..7bb82f489c 100644 --- a/desktop/ui/src/app/Connections.tsx +++ b/desktop/ui/src/app/Connections.tsx @@ -33,10 +33,20 @@ type Props = { language: Language; onState: (value: DesktopState) => void; onDirty: (value: boolean) => void; + // External selection is accepted only after the application's draft guard. + selection?: { id: string | null; request: number }; }; -export function Connections({ state, language, onState, onDirty }: Props) { +export function Connections({ + state, + language, + onState, + onDirty, + selection, +}: Props) { const t = connectionMessages[language]; - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState( + selection?.id ?? null, + ); const profile = state?.profiles.find((p) => p.id === selected); const report = state?.reports.find((r) => r.connectionId === selected); const [name, setName] = useState(""); @@ -78,6 +88,13 @@ export function Connections({ state, language, onState, onDirty }: Props) { useEffect(() => { reset(profile); }, [selected, profile?.revision]); + useEffect(() => { + if (!selection) return; + lifetime.current++; + setBusy(false); + setSelected(selection.id); + reset(state?.profiles.find((item) => item.id === selection.id)); + }, [selection?.request]); function change(target = false) { setDirty(true); onDirty(true); diff --git a/desktop/ui/src/app/Observation.tsx b/desktop/ui/src/app/Observation.tsx new file mode 100644 index 0000000000..ada96b0fac --- /dev/null +++ b/desktop/ui/src/app/Observation.tsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReactNode } from "react"; +import { messages, type Language } from "./messages"; +import type { ObservationTab } from "./navigation"; +import { PageFeedback } from "./PageFeedback"; + +export function Observation({ + language, + tab, + onTabChange, + panels = {}, +}: { + language: Language; + tab: ObservationTab; + onTabChange: (tab: ObservationTab) => void; + panels?: Partial>; +}) { + const t = messages[language]; + const tabs: ObservationTab[] = ["runtime", "inventory", "session"]; + return ( +
+
+ {tabs.map((item, index) => ( + + ))} +
+ {tabs.map((item) => ( + + ))} +
+ ); +} diff --git a/desktop/ui/src/app/Overview.tsx b/desktop/ui/src/app/Overview.tsx deleted file mode 100644 index 17381efc77..0000000000 --- a/desktop/ui/src/app/Overview.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright (c) 2026 OceanBase. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DesktopState } from "../generated/ipc"; -import { messages, type Language } from "./messages"; -import memoryIcon from "../assets/memory.svg"; -import connectionsIcon from "../assets/connections.svg"; -import retrievalIcon from "../assets/retrieval.svg"; - -type Page = "connections" | "memories" | "settings"; -type Props = { - state: DesktopState | null; - language: Language; - onNavigate: (page: Page) => void; - refreshing?: boolean; - onRefresh?: () => void; -}; -export function Overview({ - state, - language, - onNavigate, - refreshing = false, - onRefresh, -}: Props) { - const t = messages[language]; - const active = state?.active; - const profile = state?.profiles.find((p) => p.id === active?.connectionId); - const report = - state?.reports.find((item) => item.connectionId === active?.connectionId) ?? - active?.report; - const readiness = report?.readiness.value?.status; - const enabled = !!active?.scope && !!report?.compatibilityVerified; - const principal = report?.anonymousAccess - ? null - : report?.identity.value?.principal; - const identity = !active - ? t.unverified - : report?.anonymousAccess - ? t.anonymousIdentity - : principal - ? (principal.description ?? `${principal.type}: ${principal.id}`) - : t.unverified; - const features = [ - { icon: memoryIcon, name: t.featureSave, hint: t.featureSaveHint }, - { icon: retrievalIcon, name: t.featureSearch, hint: t.featureSearchHint }, - { icon: connectionsIcon, name: t.featureRead, hint: t.featureReadHint }, - ]; - const environment = [ - t.envService, - t.envOwnership, - t.envAgent, - t.envConfig, - t.envHostLoad, - t.envCaptureRecall, - ]; - return ( -
-
-
-

{t.current}

-
- {active && ( - - {readiness === "ready" - ? t.serviceReady - : readiness - ? t.serviceNotReady - : t.unverified} - - )} - {onRefresh && ( - - )} -
-
- {active && profile ? ( - <> -
- -
-

{profile.name}

-

{profile.endpoint}

-
- - -
-
-
-
{t.identityLabel}
-
{identity}
-
-
-
{t.authLabel}
-
- {profile.authentication === "bearer" - ? t.authBearer - : t.authAnonymous} -
-
-
-
{t.currentScopeLabel}
-
- {active.scope - ? `${active.scope.title} · ${t.exactScope}` - : t.noScopeSelected} -
-
-
-
{t.compatibilityLabel}
-
- {profile.compatibility - ? `${profile.compatibility} · ${report?.compatibilityVerified ? t.compatibilityVerified : t.unverified}` - : t.noCompatibilitySelected} -
-
-
-

{t.readinessNote}

- - ) : ( -
-

{t.noActiveTitle}

-

{t.noActiveBody}

- -
- )} -
-
-
-
-

{t.features}

-
- {features.map((feature) => ( -
-
- - - {feature.name} - -

{feature.hint}

-
- - {enabled ? t.available : t.notAvailable} - -
- ))} -

{t.catalogNote}

-
-
-
-

{t.localEnv}

- -
-
- {environment.map((item) => ( -
-
{item}
-
-
-
- ))} -
-

{t.envNote}

-
-
-
-
-

{t.gettingStarted}

-
-

{t.gettingStartedHint}

-
-
- -
-

{t.stepSave}

-

{t.stepSaveHint}

-
-
-
- -
-

{t.stepSearch}

-

{t.stepSearchHint}

-
-
-
-

{t.overviewBoundary}

-
- - -
-
-
- ); - function readyBadge(status: string | null | undefined) { - return status === "ready" ? "badge success" : "badge subtle"; - } -} diff --git a/desktop/ui/src/app/PageFeedback.tsx b/desktop/ui/src/app/PageFeedback.tsx new file mode 100644 index 0000000000..1d78839d7a --- /dev/null +++ b/desktop/ui/src/app/PageFeedback.tsx @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReactNode } from "react"; + +export function PageFeedback({ + kind, + title, + children, + action, +}: { + kind: "loading" | "empty" | "unavailable" | "error"; + title: string; + children?: ReactNode; + action?: ReactNode; +}) { + return ( +
+

{title}

+ {children &&
{children}
} + {action &&
{action}
} +
+ ); +} diff --git a/desktop/ui/src/app/SettingsHelp.tsx b/desktop/ui/src/app/SettingsHelp.tsx new file mode 100644 index 0000000000..36d5fcb4c2 --- /dev/null +++ b/desktop/ui/src/app/SettingsHelp.tsx @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef, useState } from "react"; +import type { FoundationInfo } from "../generated/ipc"; +import { Diagnostics } from "./Diagnostics"; +import { messages, type Language } from "./messages"; +import type { Theme } from "./navigation"; +import { PageFeedback } from "./PageFeedback"; + +export function SettingsHelp({ + language, + theme, + host, + onLanguage, + onTheme, + localServerRequest, +}: { + language: Language; + theme: Theme; + host: FoundationInfo | null; + onLanguage: (language: Language) => void; + onTheme: (theme: Theme) => void; + localServerRequest: number; +}) { + const t = messages[language]; + const localServer = useRef(null); + const [copyResult, setCopyResult] = useState<"copied" | "copyFailed" | null>( + null, + ); + useEffect(() => { + if (localServerRequest) { + localServer.current?.scrollIntoView?.({ block: "start" }); + localServer.current?.focus(); + } + }, [localServerRequest]); + useEffect(() => setCopyResult(null), [language]); + const guide = `https://github.com/oceanbase/powercontext/blob/master/desktop/README${language === "zh" ? ".zh" : ""}.md`; + const support = "https://github.com/oceanbase/powercontext/issues"; + async function copy(value: string) { + try { + await navigator.clipboard.writeText(value); + setCopyResult("copied"); + } catch { + setCopyResult("copyFailed"); + } + } + return ( +
+
+

{t.appearance}

+
+ + +
+
+ + +
+
+
+

+ {t.localServer} +

+ +

{t.localSetupDescription}

+

{t.existingServerHelp}

+
+
+ +
+

{t.version}

+
+
+
{t.version}
+
{host?.version ?? t.unknown}
+
+
+
{t.native}
+
{host ? t.nativeReady : t.unavailable}
+
+
+

{t.boundary}

+
+
+

{t.help}

+

{t.guideDescription}

+ {guide} +
+ +
+

{t.supportDescription}

+ {support} +
+ +
+ {copyResult && ( +

+ {t[copyResult]} +

+ )} +
+
+ ); +} diff --git a/desktop/ui/src/app/TopBar.tsx b/desktop/ui/src/app/TopBar.tsx index 274ba3b963..6feac2251f 100644 --- a/desktop/ui/src/app/TopBar.tsx +++ b/desktop/ui/src/app/TopBar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import type { DesktopState } from "../generated/ipc"; import { messages, type Language } from "./messages"; import { connectionMessages } from "./connection-messages"; @@ -22,7 +22,7 @@ import { connectionMessages } from "./connection-messages"; type Props = { state: DesktopState | null; language: Language; - onManageConnections: () => void; + onManageConnections: (connectionId?: string) => void; onOpenScopes: () => void; onDisconnect: () => void; }; @@ -35,17 +35,21 @@ export function TopBar({ }: Props) { const t = messages[language]; const ct = connectionMessages[language]; - const [open, setOpen] = useState<"connection" | "identity" | null>(null); + const [open, setOpen] = useState(false); const wrap = useRef(null); + const connectionWrap = useRef(null); + const trigger = useRef(null); + const menu = useRef(null); + const firstItem = useRef(0); + const menuId = useId(); useEffect(() => { const bar = wrap.current; if (!bar) return; - const update = () => { + const update = () => document.documentElement.style.setProperty( "--topbar-height", `${bar.getBoundingClientRect().height}px`, ); - }; update(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); @@ -57,149 +61,244 @@ export function TopBar({ }, []); useEffect(() => { if (!open) return; + const items = + menu.current?.querySelectorAll('[role="menuitem"]'); + items?.[firstItem.current === -1 ? items.length - 1 : 0]?.focus(); function onPointerDown(event: PointerEvent) { - if (event.target instanceof Node && !wrap.current?.contains(event.target)) - setOpen(null); - } - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(null); + if ( + event.target instanceof Node && + !connectionWrap.current?.contains(event.target) + ) + setOpen(false); } document.addEventListener("pointerdown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("pointerdown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; + return () => document.removeEventListener("pointerdown", onPointerDown); }, [open]); const active = state?.active; - const report = active?.report; const activeProfile = state?.profiles.find( - (p) => p.id === active?.connectionId, + (profile) => profile.id === active?.connectionId, ); + const latest = state?.reports.find( + (report) => + report.connectionId === active?.connectionId && + report.revision === active?.report.revision, + ); + const report = + latest && active && latest.checkedAt >= active.report.checkedAt + ? latest + : active?.report; const principal = report?.anonymousAccess ? null : report?.identity.value?.principal; const identityText = !active - ? t.notConnected + ? t.unverified : report?.anonymousAccess ? t.anonymousIdentity : principal ? (principal.description ?? `${principal.type}: ${principal.id}`) : t.identityUnverified; + function closeMenu() { + setOpen(false); + trigger.current?.focus(); + } return (
-
+
- {open === "connection" && ( -
- {state?.profiles.map((p) => ( + {open && ( + ); } diff --git a/desktop/ui/src/app/messages.ts b/desktop/ui/src/app/messages.ts index a2af9d486a..0cdc2ef4f3 100644 --- a/desktop/ui/src/app/messages.ts +++ b/desktop/ui/src/app/messages.ts @@ -16,10 +16,48 @@ export const messages = { zh: { - home: "总览", + observation: "观察", + runtime: "运行状态", + inventory: "当前 Scope 库存与用量", + session: "本次会话", + observationUnavailable: "此观察视图尚未可用", + runtimeDescription: + "运行状态视图将展示所连接 Server 的健康、能力和处理观察,以及单独标注的本地服务事实。当前不会自动执行检查。", + inventoryDescription: + "此视图将展示当前精确 Scope 的库存和用量。尚未接入数据;未知值不表示为零。", + sessionDescription: + "此视图将展示本次 Desktop 会话的有限请求汇总。当前尚未提供请求统计或安全诊断摘要复制。", + loading: "正在加载 Desktop 状态…", + stateFailed: "无法加载 Desktop 状态", + desktopError: "Desktop 操作失败", + retryState: "重试加载", + setUpLocally: "本地设置", + firstUseDescription: + "在本机准备 Server,或连接已有 Server,然后选择精确 Scope 开始保存、搜索和读取记忆。本地设置的可用情况见设置说明。", + selectScopeDescription: + "选择 Desktop 保存和搜索使用的精确 Scope;这不会修改 Agent 绑定。", + appearance: "语言与外观", + localServer: "本地 Server", + localSetupUnavailable: "本地安装与服务管理尚未可用", + localSetupDescription: + "此 Desktop 尚未提供安装、启动、修复或移除本地 Server 的操作。即使 Server 未运行,仍可查看本说明和本地诊断。", + existingServerHelp: + "可以按使用指南独立准备 Server,或向管理员获取地址和适用的凭据,再到“连接”保存配置并显式激活。", + help: "帮助与支持", + guideDescription: + "使用指南包含独立准备 Server 的前提、连接步骤和本地 CLI 诊断说明。复制链接后在浏览器中打开。", + supportDescription: + "在问题反馈页面报告问题;分享诊断信息前请去除凭据、内容和其他敏感信息。", + copyGuide: "复制使用指南链接", + copySupport: "复制问题反馈链接", + copied: "链接已复制。", + copyFailed: "无法复制链接,请手动选择并复制地址。", + unknown: "未知", + liveness: "存活状态", + lastChecked: "上次检查", connections: "连接", memories: "记忆", - settings: "设置与诊断", + settings: "设置与帮助", menu: "导航菜单", tagline: "确认连接状态,开始保存与检索记忆。", connectionIntro: "保存 Server 地址与凭据,在不同连接之间切换。", @@ -129,10 +167,50 @@ export const messages = { "连接已有的 PowerContext Server 并选择精确范围后,即可保存和查找记忆。", }, en: { - home: "Overview", + observation: "Observation", + runtime: "Runtime state", + inventory: "Current-Scope inventory & usage", + session: "This session", + observationUnavailable: "This observation view is not available yet", + runtimeDescription: + "This view will show connected-Server health, capabilities and processing observations, with separately labelled local service facts. It does not run checks automatically.", + inventoryDescription: + "This view will show inventory and usage for the current exact Scope. Data is not connected yet; unknown values are not zero.", + sessionDescription: + "This view will show bounded request summaries for this Desktop session. Request statistics and safe diagnostic summary copying are not available yet.", + loading: "Loading Desktop state…", + stateFailed: "Could not load Desktop state", + desktopError: "Desktop operation failed", + retryState: "Retry loading", + setUpLocally: "Set up locally", + firstUseDescription: + "Prepare a local Server or connect an existing Server, then select an exact Scope to save, search and read memories. See settings for local setup availability.", + selectScopeDescription: + "Select the exact Scope for Desktop saves and searches. This does not change Agent bindings.", + appearance: "Language & appearance", + localServer: "Local Server", + localSetupUnavailable: + "Local installation and service management are not available yet", + localSetupDescription: + "This Desktop does not yet offer local Server installation, start, repair or removal. This guidance and local diagnostics remain accessible when the Server is not running.", + existingServerHelp: + "Follow the usage guide to prepare a Server independently, or obtain its address and applicable credentials from an administrator. Save its configuration in Connections and explicitly activate it.", + help: "Help & support", + guideDescription: + "The usage guide covers prerequisites for an independently prepared Server, connection steps and local CLI diagnostics. Copy the link and open it in a browser.", + supportDescription: + "Report problems on the issue tracker. Remove credentials, content and other sensitive information before sharing diagnostics.", + copyGuide: "Copy usage guide link", + copySupport: "Copy support link", + copied: "Link copied.", + copyFailed: + "Could not copy the link. Select and copy the address manually.", + unknown: "Unknown", + liveness: "Liveness", + lastChecked: "Last checked", connections: "Connections", memories: "Memories", - settings: "Settings & diagnostics", + settings: "Settings & help", menu: "Navigation", tagline: "Check the connection status, then save and search memories.", connectionIntro: diff --git a/desktop/ui/src/app/navigation.ts b/desktop/ui/src/app/navigation.ts new file mode 100644 index 0000000000..7cdd003a55 --- /dev/null +++ b/desktop/ui/src/app/navigation.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Page = "memories" | "connections" | "observation" | "settings"; +export type ObservationTab = "runtime" | "inventory" | "session"; +export type Theme = "light" | "dark" | "system"; +export type NavigationTarget = + | { page: "memories" } + | { page: "connections"; connectionId?: string } + | { page: "observation"; tab?: ObservationTab } + | { page: "settings"; section?: "local-server" }; diff --git a/desktop/ui/src/app/style.css b/desktop/ui/src/app/style.css index a23d56477a..c02bd0bd64 100644 --- a/desktop/ui/src/app/style.css +++ b/desktop/ui/src/app/style.css @@ -318,8 +318,10 @@ main { top: calc(100% + 6px); left: 0; z-index: 20; - min-width: 260px; - max-width: 360px; + width: min(360px, calc(100vw - 32px)); + min-width: 0; + max-height: min(480px, 60dvh); + overflow-y: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; @@ -350,6 +352,75 @@ main { color: var(--muted); overflow-wrap: anywhere; } + +/* Context and page containers remain readable with long exact identities. */ +.global-context { + flex-basis: 100%; + min-width: 0; +} +.global-context p { + margin: 4px 0; + font-size: 14px; + overflow-wrap: anywhere; +} +.context-facts { + display: flex; + flex-wrap: wrap; + gap: 6px 24px; + margin: 8px 0; + font-size: 14px; +} +.context-facts > div { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + padding: 0; + border: 0; +} +.context-facts dd { + margin: 0; + overflow-wrap: anywhere; +} +.identity-details { + width: 100%; + min-width: 0; +} +.identity-details p, +.topbar-group button { + overflow-wrap: anywhere; +} +.topbar-group button { + text-align: left; + min-width: 0; +} +.observation-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.observation-tabs [aria-selected="true"] { + border-color: var(--accent); + background: var(--selected); + color: var(--accent); + font-weight: 600; +} +.page-feedback { + margin: 16px 0; +} +.page-feedback h2 { + margin-bottom: 0; +} +.page-feedback.error { + border-inline-start: 4px solid var(--accent); +} +.feedback-body p:last-child { + margin-bottom: 0; +} +[tabindex="-1"]:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} .menu-separator { border-top: 1px solid var(--border); margin: 6px 4px; @@ -1094,10 +1165,20 @@ footer { } @media (forced-colors: active) { button[aria-current="page"], - .notice { + .notice, + .observation-tabs [aria-selected="true"] { border: 2px solid Highlight; } .dot { background: CanvasText; } } + +@media (max-width: 700px), (max-height: 600px) { + .topbar { + position: static; + } + :root { + scroll-padding-top: 16px; + } +} diff --git a/desktop/ui/tests/app.test.tsx b/desktop/ui/tests/app.test.tsx index 2b13e7d2d1..f752c96ce7 100644 --- a/desktop/ui/tests/app.test.tsx +++ b/desktop/ui/tests/app.test.tsx @@ -14,22 +14,122 @@ * limitations under the License. */ -import { afterEach, expect, test } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { App } from "../src/app/App"; -afterEach(cleanup); -test("disconnected shell prevents writes and search and explains the boundary", async () => { +import { TopBar } from "../src/app/TopBar"; +import { Observation } from "../src/app/Observation"; +import { SettingsHelp } from "../src/app/SettingsHelp"; +import { MemoryWorkspace } from "../src/app/MemoryWorkspace"; +import { desktopApi, getFoundationInfo } from "../src/shared/ipc"; +import type { DesktopState, ProfileView } from "../src/generated/ipc"; + +vi.mock("../src/shared/ipc", () => ({ + getFoundationInfo: vi.fn(), + desktopApi: { + state: vi.fn(), + save: vi.fn(), + check: vi.fn(), + disconnect: vi.fn(), + scopes: vi.fn(), + selectScope: vi.fn(), + cancelScopes: vi.fn(), + cancelMemory: vi.fn(), + remember: vi.fn(), + search: vi.fn(), + atomicEntry: vi.fn(), + diagnostics: vi.fn(), + }, +})); +const firstProfile: ProfileView = { + id: "connection-a", + revision: 1, + name: "First", + endpoint: "http://127.0.0.1:8000/", + authentication: "unauthenticated_loopback", + caPem: null, + compatibility: null, + credentialState: "not_required", +}; +const secondProfile = { + ...firstProfile, + id: "connection-b", + name: "Second", + endpoint: "http://127.0.0.1:9000/", +}; +const disconnected: DesktopState = { + generation: 0, + profiles: [firstProfile, secondProfile], + reports: [], + active: null, + compatibilityProfiles: [], + pendingCredentialCleanup: 0, + lastWrite: null, +}; +const fact = { value: null, error: null }; +const report = { + connectionId: firstProfile.id, + revision: 1, + checkedAt: 1, + liveness: fact, + readiness: fact, + identity: fact, + capabilities: fact, + compatibilityVerified: true, + anonymousAccess: true, + supportedOperations: [], +}; +const scope = { + scope_id: "scope-a", + title: "Same scope title", + summary: "", + context_references: [], + external_references: [], + version: 1, +}; +const connected: DesktopState = { + ...disconnected, + generation: 1, + active: { connectionId: firstProfile.id, generation: 1, report, scope }, +}; +const nav = (name: string) => + within(screen.getByRole("navigation")).getByRole("button", { + name, + }); +const connectionTrigger = () => + screen.getByRole("button", { name: /^当前连接:/ }); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getFoundationInfo).mockResolvedValue(null); + vi.mocked(desktopApi.state).mockResolvedValue(disconnected); + vi.mocked(desktopApi.cancelMemory).mockResolvedValue(undefined); + vi.mocked(desktopApi.cancelScopes).mockResolvedValue(undefined); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test("four entries expose both first-use paths and retain disabled disconnected Memory operations", async () => { const user = userEvent.setup(); render(); - expect(screen.getByRole("heading", { name: "尚未连接服务" })).toBeTruthy(); - await user.click(screen.getByRole("button", { name: /连接已有服务/ })); - expect(screen.getByRole("heading", { name: "添加连接" })).toBeTruthy(); expect( - (screen.getByRole("button", { name: "保存配置" }) as HTMLButtonElement) - .disabled, - ).toBe(true); - await user.click(screen.getByRole("button", { name: "记忆" })); + await screen.findByRole("heading", { name: "尚未连接服务" }), + ).toBeTruthy(); + expect( + within(screen.getByRole("navigation")) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["记忆", "连接", "观察", "设置与帮助"]); + expect(screen.getByRole("heading", { level: 1, name: "记忆" })).toBeTruthy(); expect( (screen.getByRole("button", { name: "保存" }) as HTMLButtonElement) .disabled, @@ -38,22 +138,437 @@ test("disconnected shell prevents writes and search and explains the boundary", (screen.getByRole("button", { name: "搜索" }) as HTMLButtonElement) .disabled, ).toBe(true); - expect(screen.queryByText(/正文和搜索结果只在内存中使用/)).toBeNull(); + await user.click(screen.getByRole("button", { name: "本地设置" })); + const local = screen.getByRole("heading", { name: "本地 Server" }); + await waitFor(() => expect(document.activeElement).toBe(local)); + expect(screen.getByText("本地安装与服务管理尚未可用")).toBeTruthy(); + expect(desktopApi.check).not.toHaveBeenCalled(); + expect(desktopApi.diagnostics).not.toHaveBeenCalled(); + await user.click(nav("记忆")); + await user.click(screen.getByRole("button", { name: "连接已有服务" })); + expect(screen.getByRole("heading", { name: "添加连接" })).toBeTruthy(); +}); + +test("loading, failed initialization and a successful disconnected retry have distinct presentation", async () => { + const user = userEvent.setup(); + let reject!: (reason: unknown) => void; + vi.mocked(desktopApi.state).mockImplementationOnce( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); + render(); + expect(screen.getByRole("status").textContent).toBe("正在加载 Desktop 状态…"); + expect(screen.queryByRole("button", { name: "本地设置" })).toBeNull(); + await act(async () => reject({ code: "storage" })); + expect(screen.getByRole("alert").textContent).toContain( + "无法加载 Desktop 状态", + ); + expect(screen.queryByRole("button", { name: "本地设置" })).toBeNull(); + await user.click(nav("设置与帮助")); + expect(screen.getByRole("heading", { name: "本地 Server" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "重试加载" })); + await user.click(nav("记忆")); + expect(await screen.findByRole("button", { name: "本地设置" })).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); }); -test("navigation, bilingual settings and theme remain usable without the native host", async () => { + +test("settings, bilingual navigation and theme work without the native host and never invent a version", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: "设置与诊断" })); + await user.click(nav("设置与帮助")); await user.selectOptions(screen.getByLabelText("语言"), "en"); expect(screen.getByRole("heading", { level: 1 }).textContent).toBe( - "Settings & diagnostics", + "Settings & help", ); + expect(screen.getByRole("heading", { name: "Local Server" })).toBeTruthy(); + expect(screen.queryByText("0.1.0")).toBeNull(); + expect(screen.getByText("Unknown")).toBeTruthy(); await user.selectOptions(screen.getByLabelText("Theme"), "dark"); expect(document.documentElement.dataset.theme).toBe("dark"); expect(document.documentElement.lang).toBe("en"); - await user.click(screen.getByRole("button", { name: /Memories/ })); + await user.selectOptions(screen.getByLabelText("Theme"), "light"); + expect(document.documentElement.dataset.theme).toBe("light"); + await user.selectOptions(screen.getByLabelText("Theme"), "system"); + expect(document.documentElement.dataset.theme).toBe("system"); + await user.click(nav("Memories")); expect( screen.getByText("Enter keywords to find existing memories."), ).toBeTruthy(); - expect(screen.queryByRole("list")).toBeNull(); +}); + +test("Observation has three keyboard-accessible unavailable panels without automatic checks", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue(connected); + render(); + await user.click(nav("观察")); + const runtime = screen.getByRole("tab", { name: "运行状态" }); + await user.click(runtime); + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe( + screen.getByRole("tab", { name: "当前 Scope 库存与用量" }), + ); + expect(screen.getByRole("tabpanel").textContent).toContain( + "未知值不表示为零", + ); + await user.keyboard("{End}"); + expect(screen.getByRole("tabpanel").textContent).toContain( + "安全诊断摘要复制", + ); + await user.keyboard("{Home}{ArrowLeft}"); + expect(document.activeElement).toBe( + screen.getByRole("tab", { name: "本次会话" }), + ); + expect(screen.getAllByRole("tab")).toHaveLength(3); + expect(desktopApi.check).not.toHaveBeenCalled(); + expect(desktopApi.diagnostics).not.toHaveBeenCalled(); + expect(desktopApi.search).not.toHaveBeenCalled(); +}); + +test("each observation panel accepts its owning feature's content", () => { + render( + Exact Scope inventory supplied by its owner

}} + />, + ); + expect(screen.getByRole("tabpanel").textContent).toBe( + "Exact Scope inventory supplied by its owner", + ); + expect( + screen.queryByText("This observation view is not available yet"), + ).toBeNull(); +}); + +test("a saved connection menu item opens that configuration without activating it", async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole("button", { name: "本地设置" }); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /Second/ })); + expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( + "Second", + ); + expect(desktopApi.check).not.toHaveBeenCalled(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("heading", { level: 1, name: "连接" }), + ), + ); +}); + +test("canceling external connection selection keeps the editor and its draft intact", async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole("button", { name: "本地设置" }); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /First/ })); + await user.type(screen.getByLabelText("连接名称"), " draft"); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /Second/ })); + expect(confirm).toHaveBeenCalled(); + expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( + "First draft", + ); + confirm.mockReturnValue(true); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /Second/ })); + expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( + "Second", + ); + expect(desktopApi.check).not.toHaveBeenCalled(); +}); + +test("connection menu supports arrows, Home, End, Escape and focus return", async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole("button", { name: "本地设置" }); + connectionTrigger().focus(); + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: /First/ }), + ); + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: /Second/ }), + ); + await user.keyboard("{End}"); + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "管理连接" }), + ); + await user.keyboard("{Home}{ArrowUp}"); + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "管理连接" }), + ); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).toBeNull(); + expect(document.activeElement).toBe(connectionTrigger()); + await user.keyboard("{ArrowUp}"); + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "管理连接" }), + ); +}); + +test("a late save cannot replace an externally selected connection's new draft", async () => { + const user = userEvent.setup(); + let finish!: (state: DesktopState) => void; + vi.mocked(desktopApi.save).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + await screen.findByRole("button", { name: "本地设置" }); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /First/ })); + await user.type(screen.getByLabelText("连接名称"), " saved"); + await user.click(screen.getByRole("button", { name: "保存配置" })); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: /Second/ })); + await act(async () => + finish({ + ...disconnected, + profiles: [ + { ...firstProfile, revision: 2, name: "First saved" }, + secondProfile, + ], + }), + ); + expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( + "Second", + ); + await user.type(screen.getByLabelText("连接名称"), " draft"); + vi.spyOn(window, "confirm").mockReturnValue(false); + await user.click(nav("观察")); + expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( + "Second draft", + ); +}); + +test("a failed disconnect preserves the draft's navigation protection", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue(connected); + vi.mocked(desktopApi.disconnect).mockRejectedValue({ code: "storage" }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + await screen.findByRole("button", { name: scope.title }); + await user.type(screen.getByLabelText("记忆内容"), "unsaved"); + await user.click(connectionTrigger()); + await user.click(screen.getByRole("menuitem", { name: "断开桌面连接" })); + await screen.findByRole("alert"); + confirm.mockClear().mockReturnValue(false); + await user.click(nav("观察")); + expect(confirm).toHaveBeenCalled(); + expect((screen.getByLabelText("记忆内容") as HTMLTextAreaElement).value).toBe( + "unsaved", + ); +}); + +test("scope dialog traps focus and returns it to its trigger without losing a draft", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue(connected); + render(); + await screen.findByRole("button", { name: "Same scope title" }); + const text = screen.getByLabelText("记忆内容"); + await user.type(text, "keep this draft"); + const trigger = screen.getByRole("button", { name: "Same scope title" }); + await user.click(trigger); + const dialog = screen.getByRole("dialog"); + const close = within(dialog).getByRole("button", { name: "关闭" }); + expect(document.activeElement).toBe(close); + expect((document.querySelector(".app-background") as HTMLElement).inert).toBe( + true, + ); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(document.activeElement).toBe( + within(dialog).getByRole("button", { name: "查找范围" }), + ); + await user.keyboard("{Tab}"); + expect(document.activeElement).toBe(close); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect((text as HTMLTextAreaElement).value).toBe("keep this draft"); +}); + +test("global context distinguishes identical Scope titles by exact ID on every page", async () => { + const user = userEvent.setup(); + const next = { + ...connected, + generation: 2, + active: { + ...connected.active!, + generation: 2, + scope: { ...scope, scope_id: "scope-b" }, + }, + }; + vi.mocked(desktopApi.state).mockResolvedValue(connected); + vi.mocked(desktopApi.scopes).mockResolvedValue({ + items: [next.active.scope], + }); + vi.mocked(desktopApi.selectScope).mockResolvedValue(next); + render(); + await screen.findByRole("button", { name: scope.title }); + const context = document.querySelector(".global-context") as HTMLElement; + expect(within(context).getByText("scope-a")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: scope.title })); + await user.click(screen.getByRole("button", { name: "查找范围" })); + await user.click( + await within(screen.getByRole("dialog")).findByRole("button", { + name: scope.title, + }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + for (const page of ["连接", "观察", "设置与帮助", "记忆"]) { + await user.click(nav(page)); + expect(within(context).getByText("scope-b")).toBeTruthy(); + expect(within(context).queryByText("scope-a")).toBeNull(); + } +}); + +test("connected Memory without an exact Scope offers the existing selector", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue({ + ...connected, + active: { ...connected.active!, scope: null }, + }); + render(); + await user.click(await screen.findByRole("button", { name: "选择范围" })); + expect(screen.getByRole("dialog")).toBeTruthy(); +}); + +test("health does not turn unverified compatibility or identity into authorization", () => { + const state = { + ...connected, + active: { + ...connected.active!, + report: { + ...report, + anonymousAccess: false, + compatibilityVerified: false, + readiness: { + value: { status: "ready" as const, checks: {} }, + error: null, + }, + }, + }, + }; + render( + , + ); + expect(screen.getByText("Service ready")).toBeTruthy(); + expect( + within(document.querySelector(".global-context") as HTMLElement).getByText( + "Identity not verified yet", + ), + ).toBeTruthy(); + const compatibility = screen.getByText("Compatibility").closest("div")!; + expect(within(compatibility).getByText("Not verified")).toBeTruthy(); + expect( + screen.getByText( + /Readiness and operation permissions are verified separately/, + ), + ).toBeTruthy(); +}); + +test("help copies fixed locale-specific documentation and support links with failure feedback", async () => { + const user = userEvent.setup(); + const clipboard = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + render(); + await user.click(nav("设置与帮助")); + await user.click(screen.getByRole("button", { name: "复制使用指南链接" })); + expect(clipboard).toHaveBeenLastCalledWith( + "https://github.com/oceanbase/powercontext/blob/master/desktop/README.zh.md", + ); + expect(screen.getByText("链接已复制。")).toBeTruthy(); + await user.selectOptions(screen.getByLabelText("语言"), "en"); + await user.click( + screen.getByRole("button", { name: "Copy usage guide link" }), + ); + expect(clipboard).toHaveBeenLastCalledWith( + "https://github.com/oceanbase/powercontext/blob/master/desktop/README.md", + ); + clipboard.mockRejectedValueOnce(new Error("clipboard denied")); + await user.click(screen.getByRole("button", { name: "Copy support link" })); + expect(clipboard).toHaveBeenLastCalledWith( + "https://github.com/oceanbase/powercontext/issues", + ); + expect(screen.getByRole("alert").textContent).toMatch(/Could not copy/); +}); + +test("appearance changes retain diagnostic results and use the actual native version", async () => { + const user = userEvent.setup(); + vi.mocked(getFoundationInfo).mockResolvedValue({ + version: "9.8.7", + phase: "preview", + credentialBackend: "test", + serverConnected: false, + }); + vi.mocked(desktopApi.diagnostics).mockResolvedValue({ + checkedAt: 1, + exitCode: 0, + cliVersion: "synthetic-cli", + items: [], + hosts: [], + }); + render(); + await user.click(nav("设置与帮助")); + await user.click(screen.getByRole("button", { name: "检查本机服务" })); + expect(await screen.findByText("CLI 版本: synthetic-cli")).toBeTruthy(); + await user.selectOptions(screen.getByLabelText("语言"), "en"); + await user.selectOptions(screen.getByLabelText("Theme"), "dark"); + expect(screen.getByText("CLI version: synthetic-cli")).toBeTruthy(); + expect(screen.getByText("9.8.7")).toBeTruthy(); +}); + +test("language and theme presentation changes preserve Memory's draft", async () => { + const user = userEvent.setup(); + const props = { state: connected, onState: vi.fn(), onDirty: vi.fn() }; + const { rerender } = render(); + await user.type(screen.getByLabelText("记忆内容"), "unsaved draft"); + document.documentElement.dataset.theme = "dark"; + rerender(); + expect( + (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, + ).toBe("unsaved draft"); +}); + +test("Local Server navigation can be requested repeatedly without changing its availability", async () => { + const props = { + language: "en" as const, + theme: "system" as const, + host: null, + onLanguage: vi.fn(), + onTheme: vi.fn(), + }; + const { rerender } = render( + , + ); + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "Local Server" }), + ); + screen.getByRole("button", { name: "Copy usage guide link" }).focus(); + rerender(); + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "Local Server" }), + ); + expect( + screen.getByText( + "Local installation and service management are not available yet", + ), + ).toBeTruthy(); }); diff --git a/desktop/ui/tests/connections.test.tsx b/desktop/ui/tests/connections.test.tsx index af68391e95..3c639f1a16 100644 --- a/desktop/ui/tests/connections.test.tsx +++ b/desktop/ui/tests/connections.test.tsx @@ -311,10 +311,10 @@ test("a save from the previous connection editor cannot dismiss a new draft", as endpoint: "http://localhost:8000", }), ); - await user.click(screen.getByRole("button", { name: "总览" })); + await user.click(screen.getByRole("button", { name: "观察" })); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole("heading", { level: 1, name: "总览" }), + screen.getByRole("heading", { level: 1, name: "观察" }), ), ); await user.click(screen.getByRole("button", { name: "连接" })); @@ -328,7 +328,7 @@ test("a save from the previous connection editor cannot dismiss a new draft", as finish(state({ ...profile, name: "First" })); }); confirm.mockClear().mockReturnValue(false); - await user.click(screen.getByRole("button", { name: "总览" })); + await user.click(screen.getByRole("button", { name: "观察" })); expect(confirm).toHaveBeenCalled(); expect((screen.getByLabelText("连接名称") as HTMLInputElement).value).toBe( "New draft", diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 9b7314004f..e23489f46d 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -30,7 +30,7 @@ import { textBytes, } from "../src/app/MemoryWorkspace"; import { App } from "../src/app/App"; -import { Overview } from "../src/app/Overview"; +import { TopBar } from "../src/app/TopBar"; import { desktopApi } from "../src/shared/ipc"; import type { ArtifactReference, @@ -383,7 +383,7 @@ test.each(["forbidden", "not_found", "network"])( }, ); -test("overview refresh shows the latest readiness instead of the activation snapshot", () => { +test("global context shows the latest readiness instead of the activation snapshot", () => { const active = state.active!; const ready = { ...active.report, @@ -397,14 +397,16 @@ test("overview refresh shows the latest readiness instead of the activation snap }, }; render( - , ); expect(screen.getByText("Service not ready")).toBeTruthy(); @@ -450,10 +452,10 @@ test.each(["scope", "page"])( await user.click(screen.getByRole("button", { name: "查找范围" })); await user.click(screen.getByRole("button", { name: "Scope B" })); } else { - await user.click(screen.getByRole("button", { name: "总览" })); + await user.click(screen.getByRole("button", { name: "观察" })); await waitFor(() => expect(document.activeElement).toBe( - screen.getByRole("heading", { level: 1, name: "总览" }), + screen.getByRole("heading", { level: 1, name: "观察" }), ), ); await user.click(screen.getByRole("button", { name: "记忆" })); @@ -468,7 +470,7 @@ test.each(["scope", "page"])( finish(outcome("succeeded")); }); confirm.mockClear().mockReturnValue(false); - await user.click(screen.getByRole("button", { name: "总览" })); + await user.click(screen.getByRole("button", { name: "观察" })); expect(confirm).toHaveBeenCalled(); expect( (screen.getByLabelText("记忆内容") as HTMLTextAreaElement).value, diff --git a/desktop/ui/tests/scopes.test.tsx b/desktop/ui/tests/scopes.test.tsx index a92b3cbb93..706db6b07b 100644 --- a/desktop/ui/tests/scopes.test.tsx +++ b/desktop/ui/tests/scopes.test.tsx @@ -26,6 +26,7 @@ vi.mock("../src/shared/ipc", () => ({ desktopApi: { scopes: vi.fn(), cancelScopes: vi.fn(), + cancelMemory: vi.fn().mockResolvedValue(undefined), state: vi.fn(), selectScope: vi.fn(), }, From 7acc4966f443ecbb56b23482ad76e6fc8cbd759f Mon Sep 17 00:00:00 2001 From: sunlight798 <3281498087@qq.com> Date: Wed, 21 Oct 2026 23:13:13 +0800 Subject: [PATCH 02/12] fix(desktop): preserve existing pages and add observation Keep Overview as the initial page and Settings & diagnostics at the bottom of the sidebar. Add Observation without changing the existing page content. --- desktop/README.md | 22 +- desktop/README.zh.md | 22 +- desktop/VALIDATION.md | 18 +- desktop/tests/installed_lifecycle.py | 4 +- desktop/tests/installed_ui.py | 4 +- desktop/tests/installed_workflow.py | 2 +- desktop/ui/src/app/App.tsx | 450 +++++++++------------ desktop/ui/src/app/Connections.tsx | 21 +- desktop/ui/src/app/Observation.tsx | 3 +- desktop/ui/src/app/Overview.tsx | 230 +++++++++++ desktop/ui/src/app/SettingsHelp.tsx | 133 ------- desktop/ui/src/app/TopBar.tsx | 273 ++++--------- desktop/ui/src/app/messages.ts | 64 +-- desktop/ui/src/app/navigation.ts | 24 -- desktop/ui/src/app/style.css | 111 ++---- desktop/ui/tests/app.test.tsx | 549 +------------------------- desktop/ui/tests/connections.test.tsx | 6 +- desktop/ui/tests/memory.test.tsx | 16 +- desktop/ui/tests/observation.test.tsx | 203 ++++++++++ desktop/ui/tests/scopes.test.tsx | 1 - 20 files changed, 799 insertions(+), 1357 deletions(-) create mode 100644 desktop/ui/src/app/Overview.tsx delete mode 100644 desktop/ui/src/app/SettingsHelp.tsx delete mode 100644 desktop/ui/src/app/navigation.ts create mode 100644 desktop/ui/tests/observation.test.tsx diff --git a/desktop/README.md b/desktop/README.md index c356ac4859..96544d573b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -1,26 +1,12 @@ # PowerContext Desktop preview -Internal Windows preview for [#1654](https://github.com/oceanbase/powercontext/issues/1654), following RFC #1455. Its shared application layout provides Memory, Connections, Observation, and Settings & help, Chinese/English interfaces, and light/dark/system themes. Connection profiles, explicit activation, identity/readiness checks, exact Scope selection, local diagnostics, note saving, bounded FTS search and exact-version reading are implemented; native workflow qualification remains open. +Internal Windows preview for [#1654](https://github.com/oceanbase/powercontext/issues/1654), following RFC #1455. It provides a packaged Tauri 2 shell with Home, Connections, My memories, Chinese/English settings, semantic light/dark/system themes and honest disconnected states. Connection profiles, explicit activation, identity/readiness checks, exact Scope selection and local diagnostics are implemented. Shared note saving, bounded FTS search and exact-version reading are implemented; native workflow qualification remains open. See [中文说明](README.zh.md), [security boundary](SECURITY.md), and [validation guide](VALIDATION.md). This is not a supported or signed release, and does not close #1654 or #1428. -## Navigation and first use +## Observation -Memory is the initial page. After Desktop state loads, an inactive connection offers **Set up locally** and **Connect an existing server**. Local setup opens **Settings & help → Local Server**, which explains that installation and service management are not available yet. Prepare a Server independently using the instructions below, or obtain its address and applicable credentials from an administrator. The global connection menu opens the selected configuration without activating it. Explicitly activate a connection, select an exact Scope, and use the existing Memory workspace. - -The global context displays the connection, endpoint, exact Scope title and ID, identity, compatibility and last observed health facts on every page. Health does not grant operation permissions. Changing pages or connection targets requires confirmation when input is unsaved. Closing Scope selection leaves the current draft intact. Submitted writes retain their original target and are not automatically replayed. - -Observation contains **Runtime state**, **Current-Scope inventory & usage**, and **This session**. These are unavailable placeholders until their owning features are integrated; no sample data, invented zero-valued statistics or automatic readiness probes are shown. Connections retains its explicit connection checks, whose native readiness behavior is tracked separately by #2101. - -Settings & help remains available without HTTP and provides language, theme, Local Server guidance, explicit local CLI diagnostics, the observed Desktop version, and copyable usage/support links. An unavailable native version is shown as unknown. Local diagnostic failure does not disable independently available remote operations. - -Use Tab to reach navigation and controls. The connection menu supports arrow keys, Home/End and Escape; Observation tabs support Left/Right and Home/End. Scope selection contains keyboard focus and restores it to the opening control when closed. Small windows use collapsible navigation; context and dialog contents can scroll without hiding recovery controls. - -### UI integration interfaces - -`ui/src/app/navigation.ts` defines `Page`, `ObservationTab`, `Theme`, and `NavigationTarget`. Application navigation accepts an optional connection ID, Observation tab, or `local-server` settings section. External connection selections pass the application's draft guard before reaching the editor. The editor's `selection` request invalidates pending editor completions, so a late save cannot replace a different selected configuration. - -`Observation` accepts independent `panels` keyed by `runtime`, `inventory`, and `session`, with unavailable defaults. Each owning page provides data, authorization, refresh and cancellation behavior. `PageFeedback` presents loading/error/empty/unavailable states with caller-owned explanations and actions. Local setup and management belong to #2117; observation content belongs to #2105, #2106 and #2107. These interfaces do not extend native IPC or the public HTTP contract. +The sidebar provides Overview, Connections, Memories, and Observation, with Settings & diagnostics at the bottom. Overview is the initial page. Observation contains Runtime state, Current-Scope inventory & usage, and This session tabs. Their data views are explicitly unavailable until integrated; opening the page or switching tabs does not run connection checks or local diagnostics. English/Chinese and existing theme settings apply to Observation. ## Run the installed preview @@ -77,7 +63,7 @@ Profiles persist under the app data directory; credentials never appear in profi ## Save, find and read a note -After explicitly activating a qualified connection and choosing a Scope, open **Memories** and enter a note in the save form. The target connection and exact Scope are shown before submission. Enter inserts a newline; only the save button submits. The input is plain text, limited conservatively to 8192 raw UTF-8 bytes without truncation. The Server owns normalization and the returned text is authoritative. +After explicitly activating a qualified connection and choosing a Scope, use Home's note form or **My memories → Add note**. The target connection and exact Scope are shown before submission. Enter inserts a newline; only the save button submits. The input is plain text, limited conservatively to 8192 raw UTF-8 bytes without truncation. The Server owns normalization and the returned text is authoritative. Search uses FTS in the selected Scope and returns at most 10 matches. This is not a full directory or history, and ten matches do not establish a total count. **Read exact version** reads the returned Atomic Memory reference (`{family: "atomic-memory", artifact_id, revision}`) at that exact revision; it never substitutes the latest version. Copy buttons explicitly copy either the full plain text or the reference JSON. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 90665fd4df..f56b34c6e7 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -1,26 +1,12 @@ # PowerContext Desktop 预览版 -这是 #1654 的内部工程预览。已建立 Tauri 2、React、TypeScript 和 Vite 独立工程,提供记忆、连接、观察、设置与帮助四个入口,以及中英文界面和浅色/深色/系统主题。无连接时保存和搜索不可用,不展示示例业务数据。 +这是 #1654 的内部工程预览。已建立 Tauri 2、React、TypeScript 和 Vite 独立工程,提供首页、连接、我的记忆、双语设置及浅色/深色/系统主题。无连接时保存和搜索不可用,不展示示例业务数据。 已实现连接配置、显式激活、身份与就绪检查、精确范围选择和本地诊断。已实现共享笔记表单、FTS 搜索和精确版本阅读;原生交互闭环仍待完整验收。当前不是已签名的正式发行版。 -## 导航与首次使用 +## 观察 -默认进入“记忆”。Desktop 状态加载完成且没有活动连接时,提供“本地设置”和“连接已有服务”两条路径。“本地设置”进入“设置与帮助 → 本地 Server”,明确说明安装和服务管理尚未可用。可以按下文指引独立准备 Server,或向管理员获取地址和适用凭据。从全局连接菜单选择已保存配置只会打开对应编辑器,不会激活连接。显式激活连接并选择精确 Scope 后,使用现有记忆工作区。 - -全局上下文在各页面展示连接、地址、精确 Scope 名称和 ID、身份、兼容性及上次观察到的健康事实。健康状态不代表操作权限。有未保存输入时,切换页面或连接配置需要确认;关闭 Scope 选择不会丢弃当前草稿。已提交写入始终绑定原目标,不会自动重放。 - -“观察”包含“运行状态”“当前 Scope 库存与用量”“本次会话”三个标签页。在各功能接入前,明确显示尚未可用,不展示示例数据、虚构的零值统计,也不自动探测 readiness。“连接”保留现有显式连接检查,其原生 readiness 行为由 #2101 单独处理。 - -“设置与帮助”无需 HTTP 连接即可访问,包含语言、主题、本地 Server 指引、显式执行的本机 CLI 诊断、实际获取的 Desktop 版本,以及可复制的使用指南和反馈地址。无法获取原生版本时显示“未知”;本机诊断失败不会阻断独立可用的远程操作。 - -使用 Tab 到达导航与控件;连接菜单支持方向键、Home/End 和 Escape;观察标签页支持左右方向键和 Home/End。Scope 弹窗约束键盘焦点,关闭后返回打开它的控件。小窗口使用可折叠导航,上下文和弹窗内容可以滚动,不遮挡恢复操作。 - -### UI 接入接口 - -`ui/src/app/navigation.ts` 定义 `Page`、`ObservationTab`、`Theme` 和 `NavigationTarget`。应用导航支持指定连接 ID、观察标签页或 `local-server` 设置区域。外部连接选择必须先经过应用层草稿确认,再传给连接编辑器。编辑器的 `selection` 请求会使旧编辑操作的完成回调失效,避免迟到保存替换新选中的配置。 - -`Observation` 接受按 `runtime`、`inventory`、`session` 分别提供的 `panels`,默认展示尚未可用;数据、权限、刷新和取消行为由各页面实现。`PageFeedback` 统一加载、错误、空状态和不可用状态的呈现,解释和操作由调用方提供。本地安装与管理归属 #2117,观察内容归属 #2105、#2106、#2107;这些接口不扩展原生 IPC 或公共 HTTP 合同。 +侧栏提供总览、连接、记忆、观察入口,“设置与诊断”位于侧栏底部。默认进入总览。“观察”包含运行状态、当前 Scope 库存与用量、本次会话三个标签页。数据视图接入前明确显示尚未可用;进入页面或切换标签不会执行连接检查或本机诊断。观察页面沿用现有中英文和主题设置。 ## 直接运行安装版 @@ -96,7 +82,7 @@ Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service ## 保存、查找和阅读 -明确启用合格连接并选择精确范围后,打开「记忆」,在保存表单中填写笔记。提交前会显示目标连接和范围。Enter 只换行,点击保存才提交。正文是纯文本,保守限制为原始文本最多 8192 个 UTF-8 字节,不会截断;规范化由 Server 完成,以返回的正文为准。 +明确启用合格连接并选择精确范围后,在首页填写笔记,或在「我的记忆」中点击「记一条」。提交前会显示目标连接和范围。Enter 只换行,点击保存才提交。正文是纯文本,保守限制为原始文本最多 8192 个 UTF-8 字节,不会截断;规范化由 Server 完成,以返回的正文为准。 全文搜索只在当前范围执行,每次最多返回 10 条,不代表完整目录、历史或总数。阅读按返回的 Atomic Memory 引用(`{family: "atomic-memory", artifact_id, revision}`)读取该精确版本,不自动改读最新版本。复制按钮分别复制纯文本正文和引用 JSON。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index bcf0a036f0..4c4c186519 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -61,23 +61,19 @@ uv run python desktop/tests/fixture_qualification.py --server-commit "$DESKTOP_F pnpm --dir desktop tauri build --bundles nsis --ci --features ci-fixtures -- --locked ``` -## Shared application layout and navigation (#2102) +## Observation navigation (#2102) -Local verification on 2026-10-21 used Windows x64, Node 24.14.1, pnpm 11.13.1, and Rust 1.95.0 for generated-contract checking: +The application opens Overview and provides Overview, Connections, Memories, and Observation in the main navigation. Settings & diagnostics remains at the bottom. Observation provides Runtime state, Current-Scope inventory & usage, and This session tabs with unavailable data views; navigation does not initiate connection checks or local diagnostics. -- `pnpm --dir desktop test`: 48 UI behavior tests passed. These use mocked IPC and cover navigation, exact-Scope context, draft guards, late completions, keyboard/focus behavior, and existing Memory/connection/diagnostic journeys. -- `pnpm --dir desktop typecheck`, `lint`, and `build` passed. Lint includes OpenAPI drift, canonical brand-resource, and formatting checks; generated contracts were checked without updating them. -- Ruff 0.15.7 lint and formatting checks passed for the changed installed-acceptance Python scripts. Their startup/navigation selectors were updated; the installed harness itself was not executed locally. -- Headless Microsoft Edge 155.0.4283.62 exercised four pages and three Observation tabs across Chinese/English, light/dark/system, and CSS viewports 1180×800, 800×600, 400×400, and 400×300. The 24 combinations passed horizontal-overflow, Scope dialog focus/return, connection-menu, and local diagnostic error checks using synthetic IPC. The 400×300 viewport with device scale factor 2 checks layout equivalent to 800×600 at 200%; it does not establish actual WebView zoom behavior. -- Additional browser scenarios exercised draft-confirmation dismissal/acceptance at all four viewports, and disconnected first use, Local Server placement and initialization failure in both languages at 800×600, 400×400 and 400×300. Drafts survived canceled navigation, and first-use/error pages remained free of horizontal overflow. +Local verification on Windows x64 on 2026-10-21 used Node 24.14.1, pnpm 11.13.1, and Rust 1.95.0 for generated-contract checking. All 37 UI tests, type checking, lint, and the frontend production build passed. Existing Overview, connection, Memory, Scope and diagnostic behavior tests remain in place; Observation tests cover navigation, English/Chinese, keyboard tabs, unavailable states and the existing draft confirmation. -Browser checks and mocked IPC do not establish Server compatibility, installed-package behavior, actual IME, screen-reader output, native confirmation presentation, or macOS qualification. Those remain platform acceptance work under #2108/#2109. Local installation and service management remain unavailable pending #2117; observation content remains unavailable pending #2105/#2106/#2107. +Headless Edge 155.0.4283.62 with synthetic IPC checked all five pages in 24 combinations of English/Chinese, light/dark/system, and CSS viewports 1180×800, 800×600, 400×400 and 400×300. The pages had no horizontal overflow. At desktop widths, Settings & diagnostics had the same coordinates with and without the Observation entry; at small widths it remained below the main navigation. The 400×300 viewport at device scale factor 2 checks layout equivalent to 800×600 at 200%, not installed WebView zoom. Browser fixtures and mocked IPC do not establish real Server, installed-package, native IME, screen-reader or macOS qualification. -2026-10-21 的本地验证使用 Windows x64、Node 24.14.1、pnpm 11.13.1,生成合同检查使用 Rust 1.95.0。48 项 UI 行为测试及类型、lint、生产构建检查通过;UI 测试使用模拟 IPC,覆盖导航、精确 Scope、草稿保护、迟到响应、键盘与焦点,以及现有业务流程。修改的安装验收 Python 脚本通过 Ruff 0.15.7 检查,本地未执行安装验收脚本。 +应用默认进入总览,主导航包含总览、连接、记忆、观察,“设置与诊断”位于底部。观察包含运行状态、当前 Scope 库存与用量、本次会话三个标签页,数据视图尚未可用;页面及标签切换不会执行连接检查或本机诊断。 -无头 Edge 155.0.4283.62 使用合成 IPC,检查中英文、三种主题及上述四组 CSS 视口,共 24 种组合,覆盖四个页面、三个观察标签、横向溢出、Scope 弹窗焦点、连接菜单和本机诊断错误。400×300 配合设备缩放因子 2 仅验证 800×600 在 200% 下的等效布局,不代表实际 WebView 缩放验收。真实 Server、安装包、IME、屏幕阅读器、原生确认框呈现和 macOS 仍需对应平台验收;本地安装和观察数据功能仍明确标注尚未可用。 +2026-10-21 在 Windows x64 上使用 Node 24.14.1、pnpm 11.13.1 验证,生成合同检查使用 Rust 1.95.0。37 项 UI 测试、类型检查、lint 和前端生产构建通过。原有业务行为测试保留,观察测试覆盖导航、双语、键盘标签切换、不可用状态和现有草稿确认。 -额外浏览器场景验证了四组视口中的草稿确认取消/接受,以及中英文在 800×600、400×400、400×300 下的未连接首次使用、本地 Server 入口和初始化失败状态。取消导航保留草稿,首次使用及错误页面没有横向溢出。 +无头 Edge 155.0.4283.62 使用合成 IPC 检查中英文、三种主题及上述四组 CSS 视口,共 24 种组合,五个页面没有横向溢出。在桌面宽度下,有无观察入口时“设置与诊断”的坐标一致;小窗口中它仍位于主导航下方。400×300 配合设备缩放因子 2 仅检查 800×600 在 200% 下的等效布局,不代表安装版 WebView 缩放。真实 Server、安装包、原生 IME、屏幕阅读器和 macOS 仍需实际验收。 ## Remaining release qualification diff --git a/desktop/tests/installed_lifecycle.py b/desktop/tests/installed_lifecycle.py index f856ac6f05..7d58880437 100644 --- a/desktop/tests/installed_lifecycle.py +++ b/desktop/tests/installed_lifecycle.py @@ -91,8 +91,8 @@ def exercise_single_instance(client: httpx.Client, prefix: str, app: subprocess. != "single-instance unsaved draft" ): raise HarnessFailure("installed_second_launch_changed_draft") - page.button("观察") + page.button("总览") page.post("/alert/dismiss", {}) - page.button("观察") + page.button("总览") page.post("/alert/accept", {}) return {"secondLaunchesExited": 3, "originalEditorAndDraftPreserved": True} diff --git a/desktop/tests/installed_ui.py b/desktop/tests/installed_ui.py index 27a47d95ac..151db1f886 100644 --- a/desktop/tests/installed_ui.py +++ b/desktop/tests/installed_ui.py @@ -86,12 +86,12 @@ def wait_packaged_page(client: httpx.Client, prefix: str) -> str: ) response.raise_for_status() page = response.json()["value"] - if all(label in page["text"] for label in ("记忆", "连接", "观察", "设置与帮助")): + if "总览" in page["text"]: if not page["url"].startswith("http://tauri.localhost"): raise HarnessFailure("installed_resources_not_packaged") return page["url"] time.sleep(0.2) - raise HarnessFailure("installed_shell_not_rendered") + raise HarnessFailure("installed_overview_not_rendered") def screenshot(client: httpx.Client, prefix: str, artifacts: Path) -> bool: diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index e2b24b01d3..d0e793fdfa 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -370,7 +370,7 @@ def exercise_late_save(page: InstalledPage) -> None: gate.unlink() # This is shown only after the old form refreshes the native write record. page.wait_text("连接或范围已改变,旧正文已隐藏。") # noqa: RUF001 - exact localized UI - page.button("观察") + page.button("总览") page.post("/alert/dismiss", {}) if page.observe("return document.querySelector('.memory-workspace textarea')?.value;") != draft: raise HarnessFailure("installed_late_save_lost_new_draft") diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 7a15d15506..a8364ce661 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -14,73 +14,58 @@ * limitations under the License. */ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { messages, type Language } from "./messages"; import { getFoundationInfo, desktopApi } from "../shared/ipc"; import type { FoundationInfo, DesktopState } from "../generated/ipc"; import logo from "../../../src-tauri/icons/brand.png"; -import observationIcon from "../assets/overview.svg"; +import homeIcon from "../assets/overview.svg"; import connectionsIcon from "../assets/connections.svg"; import memoryIcon from "../assets/memory.svg"; import settingsIcon from "../assets/settings.svg"; +import observationIcon from "../assets/retrieval.svg"; + import { TopBar } from "./TopBar"; +import { Overview } from "./Overview"; import { MemoryWorkspace } from "./MemoryWorkspace"; +import { Diagnostics } from "./Diagnostics"; import { Connections } from "./Connections"; import { Scopes } from "./Scopes"; -import { Observation } from "./Observation"; -import { SettingsHelp } from "./SettingsHelp"; -import { PageFeedback } from "./PageFeedback"; -import type { - NavigationTarget, - ObservationTab, - Page, - Theme, -} from "./navigation"; +import { Observation, type ObservationTab } from "./Observation"; import { connectionMessages, connectionError } from "./connection-messages"; +type Page = "home" | "connections" | "memories" | "observation" | "settings"; +type Theme = "light" | "dark" | "system"; export function App() { const [language, setLanguage] = useState("zh"); const [theme, setTheme] = useState("system"); - const [page, setPage] = useState("memories"); - const [tab, setTab] = useState("runtime"); - const [connectionSelection, setConnectionSelection] = useState({ - id: null as string | null, - request: 0, - }); - const [localServerRequest, setLocalServerRequest] = useState(0); + const [page, setPage] = useState("home"); + const [observationTab, setObservationTab] = + useState("runtime"); const [menu, setMenu] = useState(false); const [host, setHost] = useState(null); const [desktop, setDesktop] = useState(null); const [nativeError, setNativeError] = useState(null); - const [loading, setLoading] = useState(true); const [dirty, setDirty] = useState(false); const [scopeOpen, setScopeOpen] = useState(false); - const heading = useRef(null); - const scopeDialog = useRef(null); - const scopeReturnFocus = useRef(null); - const t = messages[language]; - const ct = connectionMessages[language]; + const [refreshing, setRefreshing] = useState(false); function receiveState(next: DesktopState) { setDesktop((current) => !current || next.generation >= current.generation ? next : current, ); } function confirmSwitch() { - return !dirty || window.confirm(ct.discard); - } - async function loadState() { - setLoading(true); - setNativeError(null); - try { - receiveState(await desktopApi.state()); - } catch (error) { - setNativeError(error); - } finally { - setLoading(false); - } + return !dirty || window.confirm(connectionMessages[language].discard); } + const activeProfile = desktop?.profiles.find( + (p) => p.id === desktop.active?.connectionId, + ); + const activeReport = desktop?.active?.report; + const heading = useRef(null); + const t = messages[language]; + const ct = connectionMessages[language]; useEffect(() => { - void loadState(); + desktopApi.state().then(receiveState).catch(setNativeError); getFoundationInfo() .then(setHost) .catch(() => setHost(null)); @@ -91,253 +76,213 @@ export function App() { useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); - useLayoutEffect(() => { - heading.current?.focus(); - }, [page]); useEffect(() => { if (!scopeOpen) return; - const previous = scopeReturnFocus.current; - const dialog = scopeDialog.current; - const background = document.querySelector(".app-background"); - if (background) background.inert = true; - dialog?.querySelector("button")?.focus(); function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - setScopeOpen(false); - } - if (event.key !== "Tab") return; - const elements = Array.from( - dialog?.querySelectorAll( - 'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]', - ) ?? [], - ).filter((element) => !element.matches(":disabled")); - const first = elements[0]; - const last = elements[elements.length - 1]; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last?.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first?.focus(); - } + if (event.key === "Escape") setScopeOpen(false); } document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - if (background) background.inert = false; - if (previous?.isConnected) previous.focus(); - else - document - .querySelector(".topbar-group button") - ?.focus(); - }; + return () => document.removeEventListener("keydown", onKeyDown); }, [scopeOpen]); - function navigate(target: NavigationTarget) { - const samePage = target.page === page; - if (samePage && !(target.page === "connections" && target.connectionId)) { - if (target.page === "observation" && target.tab) setTab(target.tab); - if (target.page === "settings" && target.section) - setLocalServerRequest((value) => value + 1); - setMenu(false); - if (!(target.page === "settings" && target.section)) - heading.current?.focus(); - return; - } - if (!confirmSwitch()) return; + function navigate(next: Page) { + if (next === page || !confirmSwitch()) return; setDirty(false); - if (target.page === "connections") - setConnectionSelection((current) => ({ - id: target.connectionId ?? null, - request: current.request + 1, - })); - if (target.page === "observation" && target.tab) setTab(target.tab); - if (target.page === "settings") - setLocalServerRequest((value) => (target.section ? value + 1 : 0)); - setPage(target.page); + setPage(next); setMenu(false); - if (samePage) heading.current?.focus(); + requestAnimationFrame(() => heading.current?.focus()); } function disconnect() { if (!confirmSwitch()) return; + setDirty(false); void desktopApi.disconnect().then(receiveState).catch(setNativeError); } - const activeProfile = desktop?.profiles.find( - (profile) => profile.id === desktop.active?.connectionId, - ); + async function refresh() { + setRefreshing(true); + setNativeError(null); + try { + receiveState( + desktop?.active + ? await desktopApi.check(desktop.active.connectionId, false) + : await desktopApi.state(), + ); + } catch (e) { + setNativeError(e); + } finally { + setRefreshing(false); + } + } + const ready = activeReport?.readiness.value?.status === "ready"; return ( -
-
- - {t.skip} - -
- PowerContext - -
- -
- - navigate({ page: "connections", connectionId }) - } - onOpenScopes={() => { - scopeReturnFocus.current = document.activeElement as HTMLElement; - setScopeOpen(true); - }} - onDisconnect={disconnect} - /> -
-

- {t[page]} -

- {loading && } - {!loading && nativeError != null && ( - void loadState()}> - {t.retryState} - - } - > -

{connectionError(nativeError, language)}

-
- )} - {page === "memories" && ( -
- {!loading && desktop && !desktop.active && ( - - - - - } +
+ +
+ navigate("connections")} + onOpenScopes={() => setScopeOpen(true)} + onDisconnect={disconnect} + /> +
+

+ {t[page]} +

+ {nativeError != null && ( +

{connectionError(nativeError, language)}

+ )} + {page === "memories" && desktop?.active && ( +
+ {activeProfile?.name} + + {activeProfile?.endpoint} + + {desktop.active.scope?.title ?? t.noScopeSelected} + + {ready ? t.serviceReady : t.unverified} + +
+ )} + {page === "home" && ( + void refresh()} + /> + )} + {page === "memories" && ( + + )} + {page === "connections" && ( + + )} + {page === "observation" && ( + + )} + {page === "settings" && ( +
+
+
+ + +
+
+ + +
+
+ +
+

{t.version}

+
+
+
{t.version}
+
{host?.version ?? "0.1.0"}
+
+
+
{t.native}
+
+ {host ? t.nativeReady : t.unavailable} +
+
+
+

{t.boundary}

+
+
+ )} +
{scopeOpen && (

{ct.scope}

diff --git a/desktop/ui/src/app/Connections.tsx b/desktop/ui/src/app/Connections.tsx index 7bb82f489c..c691e2dad2 100644 --- a/desktop/ui/src/app/Connections.tsx +++ b/desktop/ui/src/app/Connections.tsx @@ -33,20 +33,10 @@ type Props = { language: Language; onState: (value: DesktopState) => void; onDirty: (value: boolean) => void; - // External selection is accepted only after the application's draft guard. - selection?: { id: string | null; request: number }; }; -export function Connections({ - state, - language, - onState, - onDirty, - selection, -}: Props) { +export function Connections({ state, language, onState, onDirty }: Props) { const t = connectionMessages[language]; - const [selected, setSelected] = useState( - selection?.id ?? null, - ); + const [selected, setSelected] = useState(null); const profile = state?.profiles.find((p) => p.id === selected); const report = state?.reports.find((r) => r.connectionId === selected); const [name, setName] = useState(""); @@ -88,13 +78,6 @@ export function Connections({ useEffect(() => { reset(profile); }, [selected, profile?.revision]); - useEffect(() => { - if (!selection) return; - lifetime.current++; - setBusy(false); - setSelected(selection.id); - reset(state?.profiles.find((item) => item.id === selection.id)); - }, [selection?.request]); function change(target = false) { setDirty(true); onDirty(true); diff --git a/desktop/ui/src/app/Observation.tsx b/desktop/ui/src/app/Observation.tsx index ada96b0fac..75373fe4bc 100644 --- a/desktop/ui/src/app/Observation.tsx +++ b/desktop/ui/src/app/Observation.tsx @@ -16,9 +16,10 @@ import type { ReactNode } from "react"; import { messages, type Language } from "./messages"; -import type { ObservationTab } from "./navigation"; import { PageFeedback } from "./PageFeedback"; +export type ObservationTab = "runtime" | "inventory" | "session"; + export function Observation({ language, tab, diff --git a/desktop/ui/src/app/Overview.tsx b/desktop/ui/src/app/Overview.tsx new file mode 100644 index 0000000000..17381efc77 --- /dev/null +++ b/desktop/ui/src/app/Overview.tsx @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DesktopState } from "../generated/ipc"; +import { messages, type Language } from "./messages"; +import memoryIcon from "../assets/memory.svg"; +import connectionsIcon from "../assets/connections.svg"; +import retrievalIcon from "../assets/retrieval.svg"; + +type Page = "connections" | "memories" | "settings"; +type Props = { + state: DesktopState | null; + language: Language; + onNavigate: (page: Page) => void; + refreshing?: boolean; + onRefresh?: () => void; +}; +export function Overview({ + state, + language, + onNavigate, + refreshing = false, + onRefresh, +}: Props) { + const t = messages[language]; + const active = state?.active; + const profile = state?.profiles.find((p) => p.id === active?.connectionId); + const report = + state?.reports.find((item) => item.connectionId === active?.connectionId) ?? + active?.report; + const readiness = report?.readiness.value?.status; + const enabled = !!active?.scope && !!report?.compatibilityVerified; + const principal = report?.anonymousAccess + ? null + : report?.identity.value?.principal; + const identity = !active + ? t.unverified + : report?.anonymousAccess + ? t.anonymousIdentity + : principal + ? (principal.description ?? `${principal.type}: ${principal.id}`) + : t.unverified; + const features = [ + { icon: memoryIcon, name: t.featureSave, hint: t.featureSaveHint }, + { icon: retrievalIcon, name: t.featureSearch, hint: t.featureSearchHint }, + { icon: connectionsIcon, name: t.featureRead, hint: t.featureReadHint }, + ]; + const environment = [ + t.envService, + t.envOwnership, + t.envAgent, + t.envConfig, + t.envHostLoad, + t.envCaptureRecall, + ]; + return ( +
+
+
+

{t.current}

+
+ {active && ( + + {readiness === "ready" + ? t.serviceReady + : readiness + ? t.serviceNotReady + : t.unverified} + + )} + {onRefresh && ( + + )} +
+
+ {active && profile ? ( + <> +
+ +
+

{profile.name}

+

{profile.endpoint}

+
+ + +
+
+
+
{t.identityLabel}
+
{identity}
+
+
+
{t.authLabel}
+
+ {profile.authentication === "bearer" + ? t.authBearer + : t.authAnonymous} +
+
+
+
{t.currentScopeLabel}
+
+ {active.scope + ? `${active.scope.title} · ${t.exactScope}` + : t.noScopeSelected} +
+
+
+
{t.compatibilityLabel}
+
+ {profile.compatibility + ? `${profile.compatibility} · ${report?.compatibilityVerified ? t.compatibilityVerified : t.unverified}` + : t.noCompatibilitySelected} +
+
+
+

{t.readinessNote}

+ + ) : ( +
+

{t.noActiveTitle}

+

{t.noActiveBody}

+ +
+ )} +
+
+
+
+

{t.features}

+
+ {features.map((feature) => ( +
+
+ + + {feature.name} + +

{feature.hint}

+
+ + {enabled ? t.available : t.notAvailable} + +
+ ))} +

{t.catalogNote}

+
+
+
+

{t.localEnv}

+ +
+
+ {environment.map((item) => ( +
+
{item}
+
+
+
+ ))} +
+

{t.envNote}

+
+
+
+
+

{t.gettingStarted}

+
+

{t.gettingStartedHint}

+
+
+ +
+

{t.stepSave}

+

{t.stepSaveHint}

+
+
+
+ +
+

{t.stepSearch}

+

{t.stepSearchHint}

+
+
+
+

{t.overviewBoundary}

+
+ + +
+
+
+ ); + function readyBadge(status: string | null | undefined) { + return status === "ready" ? "badge success" : "badge subtle"; + } +} diff --git a/desktop/ui/src/app/SettingsHelp.tsx b/desktop/ui/src/app/SettingsHelp.tsx deleted file mode 100644 index 36d5fcb4c2..0000000000 --- a/desktop/ui/src/app/SettingsHelp.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2026 OceanBase. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useRef, useState } from "react"; -import type { FoundationInfo } from "../generated/ipc"; -import { Diagnostics } from "./Diagnostics"; -import { messages, type Language } from "./messages"; -import type { Theme } from "./navigation"; -import { PageFeedback } from "./PageFeedback"; - -export function SettingsHelp({ - language, - theme, - host, - onLanguage, - onTheme, - localServerRequest, -}: { - language: Language; - theme: Theme; - host: FoundationInfo | null; - onLanguage: (language: Language) => void; - onTheme: (theme: Theme) => void; - localServerRequest: number; -}) { - const t = messages[language]; - const localServer = useRef(null); - const [copyResult, setCopyResult] = useState<"copied" | "copyFailed" | null>( - null, - ); - useEffect(() => { - if (localServerRequest) { - localServer.current?.scrollIntoView?.({ block: "start" }); - localServer.current?.focus(); - } - }, [localServerRequest]); - useEffect(() => setCopyResult(null), [language]); - const guide = `https://github.com/oceanbase/powercontext/blob/master/desktop/README${language === "zh" ? ".zh" : ""}.md`; - const support = "https://github.com/oceanbase/powercontext/issues"; - async function copy(value: string) { - try { - await navigator.clipboard.writeText(value); - setCopyResult("copied"); - } catch { - setCopyResult("copyFailed"); - } - } - return ( -
-
-

{t.appearance}

-
- - -
-
- - -
-
-
-

- {t.localServer} -

- -

{t.localSetupDescription}

-

{t.existingServerHelp}

-
-
- -
-

{t.version}

-
-
-
{t.version}
-
{host?.version ?? t.unknown}
-
-
-
{t.native}
-
{host ? t.nativeReady : t.unavailable}
-
-
-

{t.boundary}

-
-
-

{t.help}

-

{t.guideDescription}

- {guide} -
- -
-

{t.supportDescription}

- {support} -
- -
- {copyResult && ( -

- {t[copyResult]} -

- )} -
-
- ); -} diff --git a/desktop/ui/src/app/TopBar.tsx b/desktop/ui/src/app/TopBar.tsx index 6feac2251f..274ba3b963 100644 --- a/desktop/ui/src/app/TopBar.tsx +++ b/desktop/ui/src/app/TopBar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { DesktopState } from "../generated/ipc"; import { messages, type Language } from "./messages"; import { connectionMessages } from "./connection-messages"; @@ -22,7 +22,7 @@ import { connectionMessages } from "./connection-messages"; type Props = { state: DesktopState | null; language: Language; - onManageConnections: (connectionId?: string) => void; + onManageConnections: () => void; onOpenScopes: () => void; onDisconnect: () => void; }; @@ -35,21 +35,17 @@ export function TopBar({ }: Props) { const t = messages[language]; const ct = connectionMessages[language]; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState<"connection" | "identity" | null>(null); const wrap = useRef(null); - const connectionWrap = useRef(null); - const trigger = useRef(null); - const menu = useRef(null); - const firstItem = useRef(0); - const menuId = useId(); useEffect(() => { const bar = wrap.current; if (!bar) return; - const update = () => + const update = () => { document.documentElement.style.setProperty( "--topbar-height", `${bar.getBoundingClientRect().height}px`, ); + }; update(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); @@ -61,244 +57,149 @@ export function TopBar({ }, []); useEffect(() => { if (!open) return; - const items = - menu.current?.querySelectorAll('[role="menuitem"]'); - items?.[firstItem.current === -1 ? items.length - 1 : 0]?.focus(); function onPointerDown(event: PointerEvent) { - if ( - event.target instanceof Node && - !connectionWrap.current?.contains(event.target) - ) - setOpen(false); + if (event.target instanceof Node && !wrap.current?.contains(event.target)) + setOpen(null); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(null); } document.addEventListener("pointerdown", onPointerDown); - return () => document.removeEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; }, [open]); const active = state?.active; + const report = active?.report; const activeProfile = state?.profiles.find( - (profile) => profile.id === active?.connectionId, + (p) => p.id === active?.connectionId, ); - const latest = state?.reports.find( - (report) => - report.connectionId === active?.connectionId && - report.revision === active?.report.revision, - ); - const report = - latest && active && latest.checkedAt >= active.report.checkedAt - ? latest - : active?.report; const principal = report?.anonymousAccess ? null : report?.identity.value?.principal; const identityText = !active - ? t.unverified + ? t.notConnected : report?.anonymousAccess ? t.anonymousIdentity : principal ? (principal.description ?? `${principal.type}: ${principal.id}`) : t.identityUnverified; - function closeMenu() { - setOpen(false); - trigger.current?.focus(); - } return (
-
+
- {open && ( - {state && state.profiles.length === 0 && ( -

{messages[language].noConnections}

+ +

{messages[language].connectionsEmptyHint}

+
)}
    {state?.profiles.map((p) => ( @@ -219,9 +226,14 @@ export function Connections({ state, language, onState, onDirty }: Props) { {isActive && {t.activeBadge}}
{dirty &&

{t.dirty}

} - {error != null && ( -

{connectionError(error, language)}

+ {busy && ( + )} + {error != null && }

{t.compatibilityHint}

{unavailableCompatibility && ( -

- {selectedCompatibility - ? t.profileContractMismatch - : t.profileMissing} -

+ +

{messages[language].recoverCompatibility}

+
)}
{t.ca} diff --git a/desktop/ui/src/app/Diagnostics.tsx b/desktop/ui/src/app/Diagnostics.tsx index 00ce5727e6..884030abd9 100644 --- a/desktop/ui/src/app/Diagnostics.tsx +++ b/desktop/ui/src/app/Diagnostics.tsx @@ -17,7 +17,8 @@ import { useState } from "react"; import { desktopApi } from "../shared/ipc"; import type { DiagnosticKind, DiagnosticReport } from "../generated/ipc"; -import type { Language } from "./messages"; +import { messages, type Language } from "./messages"; +import { PageFeedback } from "./PageFeedback"; import { connectionError } from "./connection-messages"; const labels: Record = { support: ["平台支持", "Platform support"], @@ -57,6 +58,7 @@ const labels: Record = { }; export function Diagnostics({ language }: { language: Language }) { const zh = language === "zh"; + const t = messages[language]; const [busy, setBusy] = useState(false); const [report, setReport] = useState(null); const [error, setError] = useState(null); @@ -98,22 +100,38 @@ export function Diagnostics({ language }: { language: Language }) {
{busy && ( -

- {zh ? "正在检查本机,请稍候…" : "Checking this computer…"} -

+ + )} + {!busy && !report && error == null && ( + )} {error != null && ( -

- {code === "not_found" - ? zh - ? "未找到已登记的本机 CLI;远程连接不受影响。" - : "No registered local CLI was found; remote connections are unaffected." - : code === "compatibility_unverified" + + ? "未找到已登记的本机 CLI;远程连接不受影响。" + : "No registered local CLI was found; remote connections are unaffected." + : code === "compatibility_unverified" + ? zh + ? "本机 CLI 注册未通过校验,请核对程序路径和摘要。" + : "The local CLI registration did not pass verification. Check its executable path and digest." + : connectionError(error, language) + } + action={ + kind && ( + + ) + } + /> )} {report && (

@@ -136,6 +154,9 @@ export function Diagnostics({ language }: { language: Language }) { CLI {zh ? "版本" : "version"}:{" "} {report.cliVersion ?? (zh ? "未知" : "Unknown")}

+ {!report.items.length && !report.hosts.length && ( + + )}
{report.items.map((item) => (
diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 6ed3b66046..09d34de9f9 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -25,13 +25,16 @@ import type { import { desktopApi } from "../shared/ipc"; import { connectionError } from "./connection-messages"; import { memoryMessages } from "./memory-messages"; -import type { Language } from "./messages"; +import { messages, type Language } from "./messages"; +import { PageFeedback, ErrorFeedback } from "./PageFeedback"; import memoryIcon from "../assets/memory.svg"; type Props = { state: DesktopState | null; language: Language; onState: (state: DesktopState) => void; onDirty: (dirty: boolean) => void; + onManageConnections?: () => void; + onOpenScopes?: () => void; }; export const textBytes = (value: string) => new TextEncoder().encode(value).length; @@ -148,28 +151,41 @@ export function NoteForm({ state, language, onState, onDirty }: Props) { t.noScope )}

- {bytes > 8192 &&

{t.bytesError}

} + {busy && } + {bytes > 8192 && ( + + )} {error != null && ( -

- {t.failed} {connectionError(error, language)} -

+ )} {outcome && ( -

- {outcome.record.status === "succeeded" - ? outcome.result?.records.length - ? t.success - : t.noEntry - : outcome.record.status === "unknown" - ? t.unknown - : `${t.failed} ${connectionError(outcome.record.error, language)}`} -

+ )} ); } export function MemoryWorkspace(props: Props) { const { state, language, onState, onDirty } = props; + const common = messages[language]; + const failedReference = useRef(undefined); const t = memoryMessages[language]; const active = state?.active; const readerHeading = useRef(null); @@ -187,6 +203,7 @@ export function MemoryWorkspace(props: Props) { current.current = active?.generation; const enabled = !!active?.scope && active.report.compatibilityVerified; function clearRead() { + failedReference.current = undefined; sequence.current++; setHits(null); setEntry(null); @@ -217,6 +234,7 @@ export function MemoryWorkspace(props: Props) { if (!active?.scope) return; const generation = active.generation; const ticket = ++sequence.current; + failedReference.current = reference; setError(null); setEntry(null); setCopied(""); @@ -270,6 +288,32 @@ export function MemoryWorkspace(props: Props) { const record = state?.lastWrite; return (
+ {state && !enabled && ( + + {common.chooseConnection} + + ) + : props.onOpenScopes && ( + + ) + } + /> + )} + {record && (record.status === "unknown" || record.status === "pending" || @@ -334,9 +378,31 @@ export function MemoryWorkspace(props: Props) {

{t.searchSummary}

{!enabled &&

{t.noScope}

} - {textBytes(query) > 8192 &&

{t.inputError}

} + {busy && ( + + )} + {textBytes(query) > 8192 && ( + + )} {error != null && ( -

{connectionError(error, language)}

+ void read(failedReference.current)} + > + {common.retryRead} + + ) + } + /> )}
@@ -344,8 +410,12 @@ export function MemoryWorkspace(props: Props) {

{t.results}

- {hits === null && !busy && error == null &&

{t.start}

} - {hits?.length === 0 &&

{t.none}

} + {hits === null && !busy && error == null && ( + + )} + {hits?.length === 0 && ( + + )} {hits?.length === 10 &&

{t.limit}

} {hits && (

@@ -466,7 +536,7 @@ export function MemoryWorkspace(props: Props) { ))} ) : ( -

{t.noSources}

+ )}

{t.agentScopeNote}

diff --git a/desktop/ui/src/app/Overview.tsx b/desktop/ui/src/app/Overview.tsx index 17381efc77..46bb5d7369 100644 --- a/desktop/ui/src/app/Overview.tsx +++ b/desktop/ui/src/app/Overview.tsx @@ -16,6 +16,8 @@ import type { DesktopState } from "../generated/ipc"; import { messages, type Language } from "./messages"; +import { PageFeedback } from "./PageFeedback"; +import { activeCheckReport } from "./context"; import memoryIcon from "../assets/memory.svg"; import connectionsIcon from "../assets/connections.svg"; import retrievalIcon from "../assets/retrieval.svg"; @@ -38,9 +40,7 @@ export function Overview({ const t = messages[language]; const active = state?.active; const profile = state?.profiles.find((p) => p.id === active?.connectionId); - const report = - state?.reports.find((item) => item.connectionId === active?.connectionId) ?? - active?.report; + const report = activeCheckReport(state); const readiness = report?.readiness.value?.status; const enabled = !!active?.scope && !!report?.compatibilityVerified; const principal = report?.anonymousAccess @@ -89,6 +89,9 @@ export function Overview({ )}
+ {refreshing && ( + + )} {active && profile ? ( <>
@@ -134,18 +137,24 @@ export function Overview({

{t.readinessNote}

- ) : ( -
-

{t.noActiveTitle}

+ ) : state ? ( + onNavigate("connections")} + > + {t.connect} + + } + >

{t.noActiveBody}

- -
- )} +
+ ) : null}
diff --git a/desktop/ui/src/app/PageFeedback.tsx b/desktop/ui/src/app/PageFeedback.tsx index 1d78839d7a..de1a33b5ff 100644 --- a/desktop/ui/src/app/PageFeedback.tsx +++ b/desktop/ui/src/app/PageFeedback.tsx @@ -15,28 +15,84 @@ */ import type { ReactNode } from "react"; +import { connectionError, connectionErrorCode } from "./connection-messages"; +import { messages, type Language } from "./messages"; export function PageFeedback({ kind, title, children, action, + compact = false, + headingLevel, }: { - kind: "loading" | "empty" | "unavailable" | "error"; + kind: "loading" | "empty" | "unavailable" | "error" | "success" | "warning"; title: string; children?: ReactNode; action?: ReactNode; + compact?: boolean; + headingLevel?: 3; }) { + const Container = compact ? "div" : "section"; + const Title = headingLevel === 3 ? "h3" : compact ? "p" : "h2"; return ( -
-

{title}

+ {title} {children &&
{children}
} {action &&
{action}
} -
+ + ); +} + +export function ErrorFeedback({ + error, + language, + title, + action, +}: { + error: unknown; + language: Language; + title?: string; + action?: ReactNode; +}) { + const t = messages[language]; + const code = connectionErrorCode(error); + const hint = + code === "forbidden" + ? t.recoverAccess + : code === "unauthorized" || + code === "credential_missing" || + code === "invalid_credential" || + code === "authentication_unavailable" || + code === "credential_unavailable" + ? t.recoverCredentials + : code === "compatibility_unverified" || code === "invalid_response" + ? t.recoverCompatibility + : code === "scope_required" + ? t.recoverScope + : code === "stale_context" || code === "not_connected" + ? t.recoverContext + : code === "cursor_expired" + ? t.recoverCursor + : t.recoverOperation; + return ( + + {title &&

{connectionError(error, language)}

} +

{hint}

+
); } diff --git a/desktop/ui/src/app/Scopes.tsx b/desktop/ui/src/app/Scopes.tsx index abe3e15cb4..d4b514af6e 100644 --- a/desktop/ui/src/app/Scopes.tsx +++ b/desktop/ui/src/app/Scopes.tsx @@ -17,8 +17,9 @@ import { useEffect, useRef, useState } from "react"; import type { DesktopState, ScopeDescriptor } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; -import { connectionMessages, connectionError } from "./connection-messages"; -import type { Language } from "./messages"; +import { connectionMessages } from "./connection-messages"; +import { messages, type Language } from "./messages"; +import { PageFeedback, ErrorFeedback } from "./PageFeedback"; export function Scopes({ state, language, @@ -26,6 +27,7 @@ export function Scopes({ confirmSwitch, onSelected, hideHeading = false, + onManageConnections, }: { state: DesktopState | null; language: Language; @@ -33,14 +35,20 @@ export function Scopes({ confirmSwitch: () => boolean; onSelected?: () => void; hideHeading?: boolean; + onManageConnections?: () => void; }) { const t = connectionMessages[language]; + const common = messages[language]; + const manage = onManageConnections && ( + + ); const active = state?.active; const [query, setQuery] = useState(""); const [items, setItems] = useState(null); const [cursor, setCursor] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [operation, setOperation] = useState<"list" | "select">("list"); const sequence = useRef(0); const pendingCancellation = useRef(Promise.resolve()); const currentGeneration = useRef(active?.generation); @@ -55,6 +63,7 @@ export function Scopes({ }, [active?.generation]); async function load(next: string | null) { if (!active) return; + setOperation("list"); const generation = active.generation; const ticket = ++sequence.current; setBusy(true); @@ -92,6 +101,7 @@ export function Scopes({ } async function select(id: string) { if (!active || !confirmSwitch()) return; + setOperation("select"); setBusy(true); setError(null); try { @@ -113,7 +123,12 @@ export function Scopes({ {!hideHeading &&

{t.scope}

}

{t.scopeHint}

{!active ? ( -

{t.noConnection}

+ ) : ( <> {active.scope && ( @@ -123,11 +138,45 @@ export function Scopes({

)} {!active.report.compatibilityVerified && ( -

{t.noCompatibility}

+ )} {error != null && ( -

{connectionError(error, language)}

+ void load(null)}> + {common.restartScopeSearch} + + ) : undefined + } + /> + )} + {busy && ( + )} + {!busy && + error == null && + items === null && + active.report.compatibilityVerified && ( + +

{common.scopeIdleHint}

+
+ )}
); diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 9b7314004f..2886667879 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -95,7 +95,7 @@ const artifact: ArtifactReference = { artifact_id: "atomic-a", revision: 7, }; -function outcome(status: "succeeded" | "unknown"): WriteOutcome { +function outcome(status: "succeeded" | "unknown" | "failed"): WriteOutcome { return { record: { operationId: "operation-a", @@ -245,6 +245,53 @@ test("UTF-8 overflow and IME composition cannot accidentally submit", async () = await user.click(screen.getByRole("button", { name: "Save" })); expect(desktopApi.remember).not.toHaveBeenCalled(); }); +test.each(["en", "zh"] as const)( + "a failed save outcome explains permission recovery and retains its draft (%s)", + async (language) => { + const user = userEvent.setup(); + const failed = outcome("failed"); + failed.record.error = { + code: "forbidden", + requestId: null, + dispatched: true, + }; + vi.mocked(desktopApi.state).mockResolvedValue(state); + vi.mocked(desktopApi.remember).mockResolvedValue(failed); + const onDirty = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox"); + await user.type(input, "preserve denied draft"); + onDirty.mockClear(); + await user.click( + screen.getByRole("button", { name: language === "zh" ? "保存" : "Save" }), + ); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain( + language === "zh" + ? "当前身份无权执行此操作" + : "This identity cannot perform this operation", + ); + expect(alert.textContent).toContain( + language === "zh" + ? "申请此操作所需的权限" + : "Request permission for this operation", + ); + expect(alert.textContent).toContain( + language === "zh" ? "精确 Scope" : "exact Scope", + ); + expect((input as HTMLTextAreaElement).value).toBe("preserve denied draft"); + expect(onDirty).not.toHaveBeenCalledWith(false); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); + }, +); + test("unknown save retains the draft and does not replay automatically", async () => { const user = userEvent.setup(); form(); @@ -256,6 +303,17 @@ test("unknown save retains the draft and does not replay automatically", async ( (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, ).toBe("preserve me"); expect(desktopApi.remember).toHaveBeenCalledTimes(1); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(confirm).toHaveBeenCalledOnce(); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); + expect( + (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, + ).toBe("preserve me"); + confirm.mockReturnValue(true); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(confirm).toHaveBeenCalledTimes(2); + expect(desktopApi.remember).toHaveBeenCalledTimes(2); }); test("search opens only the exact reference and renders hostile text literally", async () => { const user = userEvent.setup(); From 82d62e944b199f034c5e733b6c7911b8c1f72ad5 Mon Sep 17 00:00:00 2001 From: sunlight798 <3281498087@qq.com> Date: Thu, 22 Oct 2026 11:16:14 +0800 Subject: [PATCH 05/12] feat(desktop): add help and guard pending connection changes --- desktop/README.md | 4 + desktop/README.zh.md | 4 + desktop/VALIDATION.md | 12 + desktop/ui/src/app/App.tsx | 27 ++- desktop/ui/src/app/Connections.tsx | 18 +- desktop/ui/src/app/Help.tsx | 89 +++++++ desktop/ui/src/app/MemoryWorkspace.tsx | 33 ++- desktop/ui/src/app/TopBar.tsx | 14 +- desktop/ui/src/app/messages.ts | 47 ++++ desktop/ui/src/app/style.css | 9 + .../ui/tests/connection-transition.test.tsx | 218 ++++++++++++++++++ desktop/ui/tests/help.test.tsx | 121 ++++++++++ 12 files changed, 577 insertions(+), 19 deletions(-) create mode 100644 desktop/ui/src/app/Help.tsx create mode 100644 desktop/ui/tests/connection-transition.test.tsx create mode 100644 desktop/ui/tests/help.test.tsx diff --git a/desktop/README.md b/desktop/README.md index 3702029d7f..8785eecc0d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -17,6 +17,10 @@ Errors explain the affected operation and recovery. Read failures offer an expli The global bar shows the active connection, full Server address, Scope title and complete `scope_id` on every page. Identically named Scopes remain distinguishable. Identity, compatibility, liveness and readiness are separate facts; the latest check for the active configuration revision supplies the displayed facts and check time. A healthy Server does not grant operation permissions. Addresses and IDs wrap in small windows. The connection menu supports arrow keys, Home/End and Escape; the Scope dialog constrains focus and restores it when closed. +Settings & diagnostics includes bilingual usage guidance, a locale-specific documentation address and a fixed issue-report address. Copy actions report success or failure; the read-only address fields select their text on focus for manual copying. Help is available without a Server or native connection state. The displayed Desktop version comes from the native host and is Unknown if unavailable. + +While a connection operation is pending, a global message remains visible across page changes. You can browse other pages, but Memory editing, saving, searching, exact reads and Scope switching pause until the operation and its native-state refresh finish. This prevents a late activation result from clearing text entered against the old Scope. Once the operation completes, explicitly select an exact Scope if needed. Existing draft confirmations and unknown-write protections continue to apply. + ## Run the installed preview Download and extract `desktop-windows-internal-unsigned` from a successful **Desktop validation** Actions run on this branch. The installer is under `src-tauri/target/release/bundle/nsis/`. Its accompanying `.artifacts/windows-smoke.json` records the commit, SHA-256 and signature status; compare the downloaded installer with `Get-FileHash -Algorithm SHA256 `. The current package is unsigned and intended for internal validation. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 98ee1d1981..e2a45cae66 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -17,6 +17,10 @@ 全局栏在每个页面展示当前连接、完整 Server 地址、Scope 标题和完整 `scope_id`,可区分同名 Scope。身份、兼容性、存活和就绪状态分别呈现,采用与当前配置版本一致的最近检查事实并显示检查时间;健康状态不代表操作权限。长地址和 ID 在小窗口下可换行。连接菜单支持方向键、Home/End 和 Escape;Scope 对话框约束焦点,关闭后恢复焦点。 +“设置与诊断”内提供双语使用说明、对应语言的文档地址和固定问题反馈地址。复制操作显示成功或失败;只读地址框获取焦点时选中全文,便于手动复制。没有 Server 或本机连接状态不可用时仍可访问帮助。Desktop 版本来自原生信息,获取不到时显示“未知”。 + +连接操作等待期间,全局提示在页面切换后持续显示。仍可浏览其他页面,但记忆编辑、保存、搜索、精确读取及 Scope 切换暂停,直到操作及其原生状态刷新完成,避免晚到的激活结果清空在旧 Scope 下新输入的正文。操作完成后,按需显式选择精确 Scope;现有草稿确认和未知写入保护继续生效。 + ## 直接运行安装版 在本分支的 GitHub Actions「Desktop validation」成功运行中,下载 `desktop-windows-internal-unsigned` 产物并解压,找到 `src-tauri/target/release/bundle/nsis/` 中的安装程序。对应 `.artifacts/windows-smoke.json` 记录提交、安装包 SHA-256 和签名状态,可用 `Get-FileHash -Algorithm SHA256 <安装包路径>` 核对下载文件。当前产物未签名,供内部验证。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 4c59ecbcbf..6e4a253e7d 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -94,6 +94,18 @@ On 2026-10-22, mocked-IPC UI tests verified that a resolved `WriteOutcome` with 2026-10-22 的模拟 IPC 测试验证:确定失败的保存结果显示与异常分支一致的中英文权限恢复说明,保留草稿及未保存状态,不自动重放写入;未知写入取消再次提交时不发请求,确认后才提交。16 项 Memory 测试、全部 52 项前端测试、typecheck、lint 和生产构建通过。本次未执行真实原生/后端或安装包验收;仓库钩子仍有上述既有 Windows Python 类型检查错误。 +## Help and pending-connection protection (#2102) + +On 2026-10-22, the pinned Windows tools above ran all 62 frontend tests, typecheck, lint and production build successfully. Ten focused help/connection tests cover bilingual guidance, fixed-address copying, success/failure feedback, keyboard selection for manual copying, offline access, native/unknown version display, pending activation and active-connection recheck across page changes, native failure recovery, overlapping checks, explicit Scope selection and the new draft's discard guard. Existing late-write, unknown-write and editor lifetime tests remain passing. + +Headless Edge 155.0.4283.62 exercised 18 combinations of English/Chinese, light/dark/system and CSS viewports 800×600, 400×400 and 400×300. Synthetic IPC delayed activation across Memory and Settings navigation; the old context stayed non-editable, completion required a new Scope, and cancellation of navigation preserved a new draft. Clipboard stubs checked the locale-specific documentation address, copy failure and manual address selection. Help, pending feedback and confirmation paths had no horizontal overflow. An additional forced-colors view was inspected. The 400×300 viewport at device scale factor 2 is a layout equivalent of 800×600 at 200%, not installed WebView zoom. + +Screenshots of help copy failure and pending activation were inspected. Evidence is under ignored `.artifacts/desktop-shell/help-transition-*`. Clipboard stubs and synthetic IPC do not qualify the native clipboard, actual Server or installed WebView. No installer, platform IME, screen-reader or macOS acceptance was executed for these changes. Repository hooks retain the previously documented Windows Python `ty check` failures; the other hooks passed. + +2026-10-22 使用上述固定工具版本,全部 62 项前端测试、typecheck、lint 和生产构建通过。新增 10 项专项测试覆盖双语帮助、固定地址复制与失败后的手动复制、离线入口、原生/未知版本,以及跨页激活等待、失败恢复、重叠请求和新草稿确认保护。原有晚到写入、未知写入及连接编辑器保护测试继续通过。 + +Edge 检查覆盖中英文、三种主题和上述三个窗口尺寸的 18 组组合。合成 IPC 与剪贴板桩验证帮助复制、跨页等待、旧上下文不可编辑、完成后显式选择 Scope 及新草稿导航取消;页面没有横向溢出,并检查强制高对比度视图及截图。200% 为等效布局检查。本次未执行原生剪贴板、真实 Server、安装包、IME、屏幕阅读器或 macOS 验收;仓库钩子仍有既有 Windows Python 类型检查错误,其余通过。 + ## Remaining release qualification - Clean standard-user Windows 11, including absent/present WebView2 and bootstrap recovery. diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 20cd7b7a4f..0bdd2f39f7 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -28,6 +28,7 @@ import observationIcon from "../assets/retrieval.svg"; import { TopBar } from "./TopBar"; import { Overview } from "./Overview"; import { MemoryWorkspace } from "./MemoryWorkspace"; +import { Help } from "./Help"; import { Diagnostics } from "./Diagnostics"; import { Connections } from "./Connections"; import { Scopes } from "./Scopes"; @@ -54,6 +55,12 @@ export function App() { const [dirty, setDirty] = useState(false); const [scopeOpen, setScopeOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [pendingConnections, setPendingConnections] = useState(0); + const contextPending = pendingConnections > 0; + function connectionPending(pending: boolean) { + // Pending operations may finish after their editor has unmounted. + setPendingConnections((current) => current + (pending ? 1 : -1)); + } function receiveState(next: DesktopState) { setDesktop((current) => !current || next.generation >= current.generation ? next : current, @@ -161,11 +168,17 @@ export function App() { } function disconnect() { if (!confirmSwitch()) return; - void desktopApi.disconnect().then(receiveState).catch(setNativeError); + connectionPending(true); + void desktopApi + .disconnect() + .then(receiveState) + .catch(setNativeError) + .finally(() => connectionPending(false)); } async function refresh() { setRefreshing(true); setNativeError(null); + connectionPending(true); try { receiveState( desktop?.active @@ -175,6 +188,7 @@ export function App() { } catch (e) { setNativeError(e); } finally { + connectionPending(false); setRefreshing(false); } } @@ -250,6 +264,7 @@ export function App() { state={desktop} language={language} loading={stateLoading} + contextPending={contextPending} onManageConnections={() => navigate("connections")} onOpenScopes={openScopes} onDisconnect={disconnect} @@ -258,6 +273,11 @@ export function App() {

{t[page]}

+ {contextPending && ( + +

{t.connectionUpdatingHint}

+
+ )} {stateLoading && ( )} @@ -315,6 +335,7 @@ export function App() { onDirty={setDirty} onManageConnections={() => navigate("connections")} onOpenScopes={openScopes} + contextPending={contextPending} /> )} {page === "connections" && ( @@ -323,6 +344,7 @@ export function App() { language={language} onState={receiveState} onDirty={setDirty} + onPendingChange={connectionPending} /> )} {page === "observation" && ( @@ -359,13 +381,14 @@ export function App() {
+

{t.version}

{t.version}
-
{host?.version ?? "0.1.0"}
+
{host?.version ?? t.unknown}
{t.native}
diff --git a/desktop/ui/src/app/Connections.tsx b/desktop/ui/src/app/Connections.tsx index 52fb196354..1b6fa563ce 100644 --- a/desktop/ui/src/app/Connections.tsx +++ b/desktop/ui/src/app/Connections.tsx @@ -34,8 +34,15 @@ type Props = { language: Language; onState: (value: DesktopState) => void; onDirty: (value: boolean) => void; + onPendingChange?: (pending: boolean) => void; }; -export function Connections({ state, language, onState, onDirty }: Props) { +export function Connections({ + state, + language, + onState, + onDirty, + onPendingChange, +}: Props) { const t = connectionMessages[language]; const [selected, setSelected] = useState(null); const profile = state?.profiles.find((p) => p.id === selected); @@ -87,7 +94,12 @@ export function Connections({ state, language, onState, onDirty }: Props) { setSecret(""); if (profile && !invalidated.current) { invalidated.current = true; - void desktopApi.invalidate(profile.id).then(onState).catch(setError); + onPendingChange?.(true); + void desktopApi + .invalidate(profile.id) + .then(onState) + .catch(setError) + .finally(() => onPendingChange?.(false)); } } } @@ -101,6 +113,7 @@ export function Connections({ state, language, onState, onDirty }: Props) { const ticket = lifetime.current; setBusy(true); setError(null); + onPendingChange?.(true); try { const next = await work(); onState(next); @@ -113,6 +126,7 @@ export function Connections({ state, language, onState, onDirty }: Props) { /* Keep the explicit failure visible. */ } } finally { + onPendingChange?.(false); if (ticket === lifetime.current) setBusy(false); } } diff --git a/desktop/ui/src/app/Help.tsx b/desktop/ui/src/app/Help.tsx new file mode 100644 index 0000000000..8ce0600289 --- /dev/null +++ b/desktop/ui/src/app/Help.tsx @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef, useState } from "react"; +import { messages, type Language } from "./messages"; +import { PageFeedback } from "./PageFeedback"; + +const documentation = { + zh: "https://github.com/oceanbase/powercontext/blob/master/desktop/README.zh.md", + en: "https://github.com/oceanbase/powercontext/blob/master/desktop/README.md", +}; +const issues = "https://github.com/oceanbase/powercontext/issues"; + +export function Help({ language }: { language: Language }) { + const t = messages[language]; + const [result, setResult] = useState<{ + target: "documents" | "issues"; + success: boolean; + } | null>(null); + const copySequence = useRef(0); + useEffect(() => { + copySequence.current++; + setResult(null); + return () => { + copySequence.current++; + }; + }, [language]); + async function copy(target: "documents" | "issues") { + const ticket = ++copySequence.current; + const address = target === "documents" ? documentation[language] : issues; + try { + await navigator.clipboard.writeText(address); + if (ticket === copySequence.current) setResult({ target, success: true }); + } catch { + if (ticket === copySequence.current) + setResult({ target, success: false }); + } + } + return ( +
+

{t.help}

+

{t.helpPrepare}

+
    +
  1. {t.helpConnect}
  2. +
  3. {t.helpScope}
  4. +
  5. {t.helpMemory}
  6. +
+

{t.helpRecovery}

+

{t.helpAddressHint}

+ {(["documents", "issues"] as const).map((target) => ( +
+

{target === "documents" ? t.helpDocuments : t.helpIssues}

+ {result?.target === target && ( + + )} +