From 3b89dfac407b774718823fe1f40875b36945aada Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 03:52:36 +0800
Subject: [PATCH 01/17] =?UTF-8?q?fix(config):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E9=85=8D=E5=AF=B9=E7=8A=B6=E6=80=81=E8=AF=86=E5=88=AB=E4=B8=8E?=
=?UTF-8?q?=E9=A6=96=E6=AC=A1=E9=85=8D=E5=AF=B9=E5=BC=95=E5=AF=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增状态驱动的已配对网关识别与列表,并补齐 paired_device 的 bootstrap 配对流程与错误提示。
---
src-tauri/src/gateway/auth.rs | 51 ++++-
src-tauri/src/gateway/commands.rs | 114 +++++++++++
src-tauri/src/gateway/errors.rs | 32 +++
src-tauri/src/gateway/store.rs | 68 +++++++
src-tauri/src/gateway/types.rs | 38 +++-
src-tauri/src/lib.rs | 1 +
.../components/setup/OpenClawConfigModule.tsx | 188 +++++++++++++++++-
src/app/components/setup/SetupWizard.tsx | 110 ++++++++--
src/app/contexts/I18nContext.tsx | 86 +++++++-
src/app/contexts/OpenClawContext.tsx | 34 ++++
.../contexts/openClawConnectionPolicy.test.ts | 14 ++
src/app/contexts/openClawConnectionPolicy.ts | 4 +-
src/app/contexts/openClawStorage.test.ts | 12 +-
src/app/contexts/openClawStorage.ts | 8 +-
14 files changed, 717 insertions(+), 43 deletions(-)
diff --git a/src-tauri/src/gateway/auth.rs b/src-tauri/src/gateway/auth.rs
index 8fc82d2..163938c 100644
--- a/src-tauri/src/gateway/auth.rs
+++ b/src-tauri/src/gateway/auth.rs
@@ -42,6 +42,19 @@ pub fn select_connect_auth(
.map(ToOwned::to_owned),
_ => None,
};
+ let stored_token = stored_device_token
+ .map(|entry| entry.token.trim())
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned);
+ let bootstrap_gateway_token = match config.auth_mode {
+ GatewayAuthMode::PairedDevice if stored_token.is_none() && !retry_with_stored_device_token => config
+ .auth_secret
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned),
+ _ => None,
+ };
let auth_password = match config.auth_mode {
GatewayAuthMode::Password => config
.auth_secret
@@ -51,23 +64,22 @@ pub fn select_connect_auth(
.map(ToOwned::to_owned),
_ => None,
};
- let stored_token = stored_device_token
- .map(|entry| entry.token.trim())
- .filter(|value| !value.is_empty())
- .map(ToOwned::to_owned);
let should_use_stored_device_token = retry_with_stored_device_token
|| (matches!(config.auth_mode, GatewayAuthMode::PairedDevice)
&& explicit_gateway_token.is_none()
+ && bootstrap_gateway_token.is_none()
&& auth_password.is_none());
let resolved_device_token = if should_use_stored_device_token {
stored_token.clone()
} else {
None
};
+ let auth_bootstrap_token = bootstrap_gateway_token.clone();
let auth_token = explicit_gateway_token
.clone()
- .or_else(|| resolved_device_token.clone());
+ .or_else(|| resolved_device_token.clone())
+ .or_else(|| auth_bootstrap_token.clone());
let auth_device_token = if retry_with_stored_device_token {
stored_token.clone()
} else {
@@ -76,7 +88,7 @@ pub fn select_connect_auth(
let auth = if auth_token.is_some() || auth_password.is_some() || auth_device_token.is_some() {
Some(ConnectAuth {
token: auth_token.clone(),
- bootstrap_token: None,
+ bootstrap_token: auth_bootstrap_token,
device_token: auth_device_token,
password: auth_password,
})
@@ -165,10 +177,37 @@ mod tests {
let selected = select_connect_auth(&GatewayConnectConfig::default(), Some(&stored_entry()), false);
let auth = selected.auth.expect("device auth payload");
assert_eq!(auth.token.as_deref(), Some("stored-device-token"));
+ assert_eq!(auth.bootstrap_token, None);
assert_eq!(auth.device_token.as_deref(), None);
assert_eq!(selected.resolved_device_token.as_deref(), Some("stored-device-token"));
}
+ #[test]
+ fn paired_device_auth_uses_bootstrap_token_when_not_yet_paired() {
+ let mut config = GatewayConnectConfig::default();
+ config.auth_secret = Some("shared-token".to_string());
+
+ let selected = select_connect_auth(&config, None, false);
+ let auth = selected.auth.expect("bootstrap auth payload");
+ assert_eq!(auth.token.as_deref(), Some("shared-token"));
+ assert_eq!(auth.bootstrap_token.as_deref(), Some("shared-token"));
+ assert_eq!(auth.device_token, None);
+ assert_eq!(selected.signature_token.as_deref(), Some("shared-token"));
+ assert!(selected.resolved_device_token.is_none());
+ }
+
+ #[test]
+ fn paired_device_auth_prefers_cached_device_token_over_bootstrap_secret() {
+ let mut config = GatewayConnectConfig::default();
+ config.auth_secret = Some("shared-token".to_string());
+
+ let selected = select_connect_auth(&config, Some(&stored_entry()), false);
+ let auth = selected.auth.expect("paired auth payload");
+ assert_eq!(auth.token.as_deref(), Some("stored-device-token"));
+ assert_eq!(auth.bootstrap_token, None);
+ assert_eq!(selected.resolved_device_token.as_deref(), Some("stored-device-token"));
+ }
+
#[test]
fn token_auth_retry_attaches_device_token_without_dropping_shared_token() {
let mut config = GatewayConnectConfig::default();
diff --git a/src-tauri/src/gateway/commands.rs b/src-tauri/src/gateway/commands.rs
index e4415a7..502b501 100644
--- a/src-tauri/src/gateway/commands.rs
+++ b/src-tauri/src/gateway/commands.rs
@@ -7,10 +7,12 @@ use url::Url;
use crate::gateway::{
connector,
+ device_identity::GatewayDeviceIdentity,
discovery,
endpoint::GatewayEndpoint,
errors::GatewayErrorSummary,
state::GatewayAppState,
+ store::{list_device_auth_tokens, load_exact_device_auth_token, normalize_role, normalize_scopes},
types::{
GatewayAdvancedConnectionConfig,
GatewayAgentFileGetResult, GatewayAgentIdentityResult, GatewayAgentMemoryIndexResult,
@@ -20,6 +22,7 @@ use crate::gateway::{
GatewayAgentMemoryRuntimeStatusResult, GatewayAgentMemorySearchResult,
GatewayAgentMemoryStatusResult,
GatewayAgentMemoryTimelineAccessResult, GatewayAgentMemoryTimelineResult,
+ GatewayPairedEndpoint, GatewayPairingStatusKind, GatewayPairingStatusResult,
GatewayAgentSettingsResult, GatewayAgentSettingsUpdateInput, GatewayAgentsListResult, GatewayConfigSetResult, GatewayConnectConfig,
GatewayStatusSnapshot,
},
@@ -85,6 +88,94 @@ pub async fn gateway_saved_endpoints() -> Result, Gate
.map_err(|error| GatewayErrorSummary::from_error(&error))
}
+#[tauri::command]
+pub async fn gateway_pairing_status_lookup(
+ config: GatewayConnectConfig,
+) -> Result {
+ let endpoint = GatewayEndpoint::from_config(&config)
+ .map_err(|error| GatewayErrorSummary::from_error(&error))?;
+ let paths = resolve_store_paths();
+ let identity = GatewayDeviceIdentity::load_or_create(&paths)
+ .map_err(|error| GatewayErrorSummary::from_error(&error))?;
+ let normalized_role = normalize_role(&config.role);
+ let normalized_scopes = normalize_scopes(&config.scopes);
+ let saved_endpoints = load_saved_endpoints(&paths)
+ .map_err(|error| GatewayErrorSummary::from_error(&error))?;
+ let exact_token = load_exact_device_auth_token(
+ &paths,
+ &identity.device_id,
+ &endpoint.origin_key,
+ &normalized_role,
+ &normalized_scopes,
+ )
+ .map_err(|error| GatewayErrorSummary::from_error(&error))?;
+ let tokens = list_device_auth_tokens(&paths, &identity.device_id, &normalized_role)
+ .map_err(|error| GatewayErrorSummary::from_error(&error))?;
+
+ let mut known_paired_endpoints = tokens
+ .into_iter()
+ .map(|entry| {
+ let saved = saved_endpoints
+ .iter()
+ .find(|endpoint_item| endpoint_item.origin_key == entry.gateway_origin);
+ GatewayPairedEndpoint {
+ origin_key: entry.gateway_origin.clone(),
+ label: saved
+ .map(|endpoint_item| endpoint_item.label.clone())
+ .unwrap_or_else(|| build_gateway_label_from_origin(entry.gateway_origin.as_str())),
+ ws_url: entry.gateway_origin.clone(),
+ http_url: http_url_from_origin(entry.gateway_origin.as_str()),
+ role: entry.role.clone(),
+ scopes: entry.scopes.clone(),
+ updated_at_ms: entry.updated_at_ms,
+ was_user_selected: saved.map(|endpoint_item| endpoint_item.was_user_selected).unwrap_or(false),
+ last_success_at_ms: saved.and_then(|endpoint_item| endpoint_item.last_success_at_ms),
+ exact_match: entry.gateway_origin == endpoint.origin_key,
+ }
+ })
+ .collect::>();
+ known_paired_endpoints.sort_by(|left, right| {
+ right
+ .exact_match
+ .cmp(&left.exact_match)
+ .then_with(|| right.was_user_selected.cmp(&left.was_user_selected))
+ .then_with(|| right.last_success_at_ms.cmp(&left.last_success_at_ms))
+ .then_with(|| right.updated_at_ms.cmp(&left.updated_at_ms))
+ .then_with(|| left.label.cmp(&right.label))
+ });
+ known_paired_endpoints.dedup_by(|left, right| left.origin_key == right.origin_key);
+
+ let saved_endpoint = saved_endpoints
+ .iter()
+ .find(|endpoint_item| endpoint_item.origin_key == endpoint.origin_key)
+ .cloned();
+ let matched_endpoint = known_paired_endpoints
+ .iter()
+ .find(|endpoint_item| endpoint_item.exact_match)
+ .cloned();
+ let paired_ready = exact_token.is_some();
+
+ Ok(GatewayPairingStatusResult {
+ origin_key: endpoint.origin_key.clone(),
+ label: saved_endpoint
+ .as_ref()
+ .map(|endpoint_item| endpoint_item.label.clone())
+ .unwrap_or_else(|| build_gateway_label_from_origin(endpoint.origin_key.as_str())),
+ ws_url: endpoint.ws_url.clone(),
+ http_url: http_url_from_origin(endpoint.origin_key.as_str()),
+ status: if paired_ready {
+ GatewayPairingStatusKind::PairedReady
+ } else {
+ GatewayPairingStatusKind::BootstrapRequired
+ },
+ paired_ready,
+ bootstrap_required: !paired_ready,
+ saved_endpoint,
+ matched_endpoint,
+ known_paired_endpoints,
+ })
+}
+
#[tauri::command]
pub async fn gateway_select_endpoint(
candidate: GatewayDiscoveredCandidate,
@@ -617,3 +708,26 @@ fn export_markdown_root() -> Result {
Ok(std::env::current_dir()?.join("exports"))
}
+
+fn http_url_from_origin(origin: &str) -> Option {
+ if let Some(stripped) = origin.strip_prefix("ws://") {
+ return Some(format!("http://{stripped}"));
+ }
+ if let Some(stripped) = origin.strip_prefix("wss://") {
+ return Some(format!("https://{stripped}"));
+ }
+ None
+}
+
+fn build_gateway_label_from_origin(origin: &str) -> String {
+ Url::parse(origin)
+ .ok()
+ .map(|url| {
+ if matches!(url.host_str(), Some("127.0.0.1" | "localhost")) {
+ "OpenClaw Local".to_string()
+ } else {
+ format!("OpenClaw {}", url.host_str().unwrap_or(origin))
+ }
+ })
+ .unwrap_or_else(|| origin.to_string())
+}
diff --git a/src-tauri/src/gateway/errors.rs b/src-tauri/src/gateway/errors.rs
index 9a04a30..a049c0d 100644
--- a/src-tauri/src/gateway/errors.rs
+++ b/src-tauri/src/gateway/errors.rs
@@ -12,6 +12,8 @@ use crate::gateway::protocol::{
CONNECT_ERROR_PAIRING_REQUIRED,
};
+const CONNECT_ERROR_AUTH_TOKEN_REQUIRED: &str = "AUTH_TOKEN_REQUIRED";
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GatewayErrorSummary {
@@ -283,6 +285,11 @@ fn normalize_connect_rejection_code(code: Option<&str>, message: &str) -> Option
if normalized_message.contains("gateway token mismatch") {
return Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH.to_string());
}
+ if normalized_message.contains("gateway token missing")
+ || normalized_message.contains("token missing")
+ {
+ return Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED.to_string());
+ }
if normalized_message.contains("gateway password mismatch") {
return Some(CONNECT_ERROR_AUTH_PASSWORD_MISMATCH.to_string());
}
@@ -328,6 +335,9 @@ fn connect_rejection_message(code: Option<&str>, message: &str, pairing_reason:
Some("not-paired") => "当前设备尚未完成配对,需要先在服务端批准当前设备。".to_string(),
_ => "当前连接需要先完成设备配对批准。".to_string(),
},
+ Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED) => {
+ "当前连接缺少 Gateway Token,无法完成首次配对或重配。".to_string()
+ }
_ => message.to_string(),
}
}
@@ -361,6 +371,9 @@ fn connect_rejection_hint(
Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH) if can_retry_with_device_token => Some(
"共享 token 未通过校验,但服务端允许改用已签发的 device token。".to_string(),
),
+ Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED) => Some(
+ "当前是首次配对或重配,请在“已配对设备”模式下填写 Gateway Token 作为首次配对凭据。配对成功后会自动切换为 device token。".to_string(),
+ ),
Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH) => {
Some("请检查 Gateway token 是否与 OpenClaw 当前配置一致。".to_string())
}
@@ -491,6 +504,25 @@ mod tests {
Some("请检查 Gateway token 是否与 OpenClaw 当前配置一致。")
);
}
+
+ #[test]
+ fn token_missing_is_inferred_from_message_when_code_is_missing() {
+ let error = GatewayError::ConnectRejected {
+ code: None,
+ message: "unauthorized: gateway token missing (provide gateway auth token)".to_string(),
+ retryable: false,
+ can_retry_with_device_token: false,
+ recommended_next_step: Some("update_auth_configuration".to_string()),
+ pairing_reason: None,
+ };
+ let summary = GatewayErrorSummary::from_error(&error);
+ assert_eq!(summary.category, "auth");
+ assert_eq!(summary.code.as_deref(), Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED));
+ assert_eq!(
+ summary.hint.as_deref(),
+ Some("当前是首次配对或重配,请在“已配对设备”模式下填写 Gateway Token 作为首次配对凭据。配对成功后会自动切换为 device token。")
+ );
+ }
}
diff --git a/src-tauri/src/gateway/store.rs b/src-tauri/src/gateway/store.rs
index 473070b..93f61d3 100644
--- a/src-tauri/src/gateway/store.rs
+++ b/src-tauri/src/gateway/store.rs
@@ -153,6 +153,48 @@ pub fn load_device_auth_token(
Ok(legacy_fallback)
}
+pub fn load_exact_device_auth_token(
+ paths: &GatewayStorePaths,
+ device_id: &str,
+ gateway_origin: &str,
+ role: &str,
+ scopes: &[String],
+) -> Result
+ {showNoPendingPairingHint ? (
+
+ {t('setup.pairing.requestNotQueuedDesc')}
+
+ ) : null}
openclaw config get gateway.auth.token
+ ) : pairingFollowup === 'token_required' || pairingFollowup === 'request_not_queued' ? (
+
+
+ {t('setup.pairing.requestNotQueuedTitle')}
+
+
+ {t('setup.pairing.requestNotQueuedDesc')}
+
+ {pairingFollowup === 'token_required' ? (
+
+ openclaw config get gateway.auth.token
+
+ ) : null}
+
+ ) : pairingFollowup === 'connected_without_pairing' ? (
+
+
+ {t('setup.pairing.connectedWithoutPairingTitle')}
+
+
+ {t('setup.pairing.connectedWithoutPairingDesc')}
+
+
) : (
diff --git a/src/app/components/setup/openClawPairingState.test.ts b/src/app/components/setup/openClawPairingState.test.ts
new file mode 100644
index 0000000..96579c1
--- /dev/null
+++ b/src/app/components/setup/openClawPairingState.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ resolveOpenClawPairingFollowup,
+ resolveOpenClawStartPairingTransition,
+ shouldShowNoPendingPairingHint,
+} from "./openClawPairingState";
+
+describe("openClawPairingState", () => {
+ it("returns none before a pairing attempt is made", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: false,
+ pairingCompletionPending: false,
+ lastError: { code: "PAIRING_REQUIRED", category: "pairing" },
+ }),
+ ).toBe("none");
+ });
+
+ it("returns awaiting_host_approval when pairing is pending host approval", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: false,
+ lastError: { code: "PAIRING_REQUIRED", category: "pairing" },
+ }),
+ ).toBe("awaiting_host_approval");
+ });
+
+ it("returns token_mismatch when the bootstrap token is rejected", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: false,
+ lastError: { code: "AUTH_TOKEN_MISMATCH", category: "auth" },
+ }),
+ ).toBe("token_mismatch");
+ });
+
+ it("returns token_required when the bootstrap token is missing", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: false,
+ lastError: { code: "AUTH_TOKEN_REQUIRED", category: "auth" },
+ }),
+ ).toBe("token_required");
+ });
+
+ it("treats other failures as request_not_queued", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: false,
+ lastError: { code: "SOCKET_ERROR", category: "transport" },
+ }),
+ ).toBe("request_not_queued");
+ });
+
+ it("suppresses follow-up prompts once device token persistence is pending", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: true,
+ lastError: { code: "PAIRING_REQUIRED", category: "pairing" },
+ }),
+ ).toBe("none");
+ });
+
+ it("returns connected_without_pairing when start pairing only establishes a shared-token connection", () => {
+ expect(
+ resolveOpenClawPairingFollowup({
+ pairedReady: false,
+ pairingAttempted: true,
+ pairingCompletionPending: false,
+ pairingSucceededWithoutDeviceToken: true,
+ lastError: null,
+ }),
+ ).toBe("connected_without_pairing");
+ });
+
+ it("marks only non-approval failures as no-pending-pairing hints", () => {
+ expect(shouldShowNoPendingPairingHint("none")).toBe(false);
+ expect(shouldShowNoPendingPairingHint("awaiting_host_approval")).toBe(false);
+ expect(shouldShowNoPendingPairingHint("connected_without_pairing")).toBe(true);
+ expect(shouldShowNoPendingPairingHint("token_mismatch")).toBe(true);
+ expect(shouldShowNoPendingPairingHint("token_required")).toBe(true);
+ expect(shouldShowNoPendingPairingHint("request_not_queued")).toBe(true);
+ });
+
+ it("keeps the wizard on the pairing step when start pairing succeeds without device token issuance", () => {
+ expect(
+ resolveOpenClawStartPairingTransition({
+ connectSucceeded: true,
+ pairedReady: false,
+ }),
+ ).toEqual({
+ shouldAdvanceWizard: false,
+ pairingCompletionPending: false,
+ pairingSucceededWithoutDeviceToken: true,
+ });
+ });
+
+ it("advances the wizard only when start pairing actually finishes and device token is ready", () => {
+ expect(
+ resolveOpenClawStartPairingTransition({
+ connectSucceeded: true,
+ pairedReady: true,
+ }),
+ ).toEqual({
+ shouldAdvanceWizard: true,
+ pairingCompletionPending: true,
+ pairingSucceededWithoutDeviceToken: false,
+ });
+ });
+});
diff --git a/src/app/components/setup/openClawPairingState.ts b/src/app/components/setup/openClawPairingState.ts
new file mode 100644
index 0000000..00f6d76
--- /dev/null
+++ b/src/app/components/setup/openClawPairingState.ts
@@ -0,0 +1,97 @@
+import type { GatewayErrorSummary } from "../../contexts/OpenClawContext";
+
+export type OpenClawPairingFollowupKind =
+ | "none"
+ | "awaiting_host_approval"
+ | "connected_without_pairing"
+ | "token_mismatch"
+ | "token_required"
+ | "request_not_queued";
+
+interface ResolveOpenClawPairingFollowupInput {
+ pairedReady: boolean;
+ pairingAttempted: boolean;
+ pairingCompletionPending: boolean;
+ pairingSucceededWithoutDeviceToken?: boolean;
+ lastError: Pick | null;
+}
+
+interface ResolveOpenClawStartPairingTransitionInput {
+ connectSucceeded: boolean;
+ pairedReady: boolean;
+}
+
+interface OpenClawStartPairingTransition {
+ shouldAdvanceWizard: boolean;
+ pairingCompletionPending: boolean;
+ pairingSucceededWithoutDeviceToken: boolean;
+}
+
+export function resolveOpenClawPairingFollowup({
+ pairedReady,
+ pairingAttempted,
+ pairingCompletionPending,
+ pairingSucceededWithoutDeviceToken = false,
+ lastError,
+}: ResolveOpenClawPairingFollowupInput): OpenClawPairingFollowupKind {
+ if (pairedReady || pairingCompletionPending || !pairingAttempted) {
+ return "none";
+ }
+
+ if (pairingSucceededWithoutDeviceToken) {
+ return "connected_without_pairing";
+ }
+
+ if (!lastError) {
+ return "none";
+ }
+
+ switch (lastError.code) {
+ case "PAIRING_REQUIRED":
+ return "awaiting_host_approval";
+ case "AUTH_TOKEN_MISMATCH":
+ return "token_mismatch";
+ case "AUTH_TOKEN_REQUIRED":
+ return "token_required";
+ default:
+ return "request_not_queued";
+ }
+}
+
+export function shouldShowNoPendingPairingHint(
+ kind: OpenClawPairingFollowupKind,
+) {
+ return (
+ kind === "connected_without_pairing" ||
+ kind === "token_mismatch" ||
+ kind === "token_required" ||
+ kind === "request_not_queued"
+ );
+}
+
+export function resolveOpenClawStartPairingTransition({
+ connectSucceeded,
+ pairedReady,
+}: ResolveOpenClawStartPairingTransitionInput): OpenClawStartPairingTransition {
+ if (!connectSucceeded) {
+ return {
+ shouldAdvanceWizard: false,
+ pairingCompletionPending: false,
+ pairingSucceededWithoutDeviceToken: false,
+ };
+ }
+
+ if (pairedReady) {
+ return {
+ shouldAdvanceWizard: true,
+ pairingCompletionPending: true,
+ pairingSucceededWithoutDeviceToken: false,
+ };
+ }
+
+ return {
+ shouldAdvanceWizard: false,
+ pairingCompletionPending: false,
+ pairingSucceededWithoutDeviceToken: true,
+ };
+}
diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx
index 61b86cb..23fcb93 100644
--- a/src/app/contexts/I18nContext.tsx
+++ b/src/app/contexts/I18nContext.tsx
@@ -3778,6 +3778,26 @@ const BASE_DICT: Record = {
"当前填写的 Gateway Token 与宿主机配置不一致。请先在宿主机核对 token,再回到这里重新测试连接。",
"目前填寫的 Gateway Token 與主機設定不一致。請先在主機核對 token,再回到這裡重新測試連線。",
],
+ "setup.pairing.requestNotQueuedTitle": [
+ "This pairing attempt did not reach the host approval queue",
+ "这次配对尝试没有进入宿主机待批准队列",
+ "這次配對嘗試沒有進入主機待批准佇列",
+ ],
+ "setup.pairing.requestNotQueuedDesc": [
+ "If `openclaw devices approve --latest` returns `no device`, this attempt was rejected before host approval. Check the Gateway URL and Gateway Token first, then start pairing again.",
+ "如果宿主机执行 `openclaw devices approve --latest` 返回 `no device`,说明这次尝试在进入宿主机批准队列之前就被拒绝了。请先检查 Gateway URL 和 Gateway Token,再重新开始配对。",
+ "如果主機執行 `openclaw devices approve --latest` 回傳 `no device`,表示這次嘗試在進入主機批准佇列之前就被拒絕了。請先檢查 Gateway URL 和 Gateway Token,再重新開始配對。",
+ ],
+ "setup.pairing.connectedWithoutPairingTitle": [
+ "Connection succeeded, but first pairing is not complete yet",
+ "连接成功,但首次配对尚未完成",
+ "連線成功,但首次配對尚未完成",
+ ],
+ "setup.pairing.connectedWithoutPairingDesc": [
+ "This attempt established a shared-token connection, but the gateway did not return a device token. Stay on this step and do not continue yet. If the host shows `openclaw devices approve --latest` -> `no pending device`, the request never entered the approval queue.",
+ "这次尝试只建立了共享 token 连接,但网关没有返回 device token。请停留在当前步骤,不要继续下一页。如果宿主机执行 `openclaw devices approve --latest` 后显示 `no pending device`,说明这次请求根本没有进入待批准配对队列。",
+ "這次嘗試只建立了共享 token 連線,但網關沒有回傳 device token。請停留在目前步驟,不要繼續下一頁。如果主機執行 `openclaw devices approve --latest` 後顯示 `no pending device`,表示這次請求根本沒有進入待批准配對佇列。",
+ ],
"memory.header.agents": ["Agents", "Agents", "Agents"],
"memory.overview.pending": [
"Overview is waiting for agent resolution and the first batch of memory data.",
From 66cf29742df367d16b810db912d86e4d15f5a83e Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 09:06:50 +0800
Subject: [PATCH 05/17] =?UTF-8?q?fix(setup):=20=E6=94=B6=E5=8F=A3=E6=9C=AC?=
=?UTF-8?q?=E6=9C=BA=20loopback=20=E9=85=8D=E5=AF=B9=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
loopback 地址下隐藏已配对设备与首次配对路径,保留 token/password 连接并提示改用局域网 IP 配对。
---
.../components/setup/OpenClawConfigModule.tsx | 44 ++++++++++++++---
src/app/components/setup/SetupWizard.tsx | 47 ++++++++++++++++---
src/app/contexts/I18nContext.tsx | 10 ++++
.../contexts/openClawConnectionPolicy.test.ts | 15 ++++++
src/app/contexts/openClawConnectionPolicy.ts | 12 +++++
5 files changed, 116 insertions(+), 12 deletions(-)
diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx
index e5807cb..3dbf257 100644
--- a/src/app/components/setup/OpenClawConfigModule.tsx
+++ b/src/app/components/setup/OpenClawConfigModule.tsx
@@ -3,6 +3,10 @@ import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayAdv
import { CheckCircle2, Server, Shield, Globe, TerminalSquare, RefreshCw, XCircle, AlertCircle, RotateCcw, Trash2, Wifi } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useI18n } from '../../contexts/I18nContext';
+import {
+ resolveAuthModeForGatewayUrl,
+ shouldAllowPairingUiForGatewayUrl,
+} from '../../contexts/openClawConnectionPolicy';
import {
buildAvailableOpenClawConfigSections,
resolveSelectedOpenClawConfigSection,
@@ -62,8 +66,9 @@ export function OpenClawConfigModule() {
const [pairingAttempted, setPairingAttempted] = useState(false);
const [pairingCompletionPending, setPairingCompletionPending] = useState(false);
const [pairingSucceededWithoutDeviceToken, setPairingSucceededWithoutDeviceToken] = useState(false);
+ const pairingUiAllowed = shouldAllowPairingUiForGatewayUrl(url);
const pairedReady = pairingStatus?.pairedReady ?? false;
- const pairedDeviceAvailable = pairedReady;
+ const pairedDeviceAvailable = pairingUiAllowed && pairedReady;
const pairingFollowup = resolveOpenClawPairingFollowup({
pairedReady,
pairingAttempted,
@@ -80,7 +85,7 @@ export function OpenClawConfigModule() {
useEffect(() => {
setUrl(gatewayUrl);
- setAuthMode(savedAuthMode);
+ setAuthMode(resolveAuthModeForGatewayUrl(gatewayUrl, savedAuthMode));
setAuthSecret(savedAuthSecret);
setAuthModeTouched(false);
setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : '');
@@ -90,7 +95,7 @@ export function OpenClawConfigModule() {
}, [gatewayUrl, savedAuthMode, savedAuthSecret]);
useEffect(() => {
- if (!url.trim()) {
+ if (!url.trim() || !pairingUiAllowed) {
setPairingStatus(null);
setPairingStatusLoading(false);
return;
@@ -121,7 +126,21 @@ export function OpenClawConfigModule() {
cancelled = true;
window.clearTimeout(timer);
};
- }, [authModeTouched, url]);
+ }, [authModeTouched, pairingUiAllowed, url]);
+
+ useEffect(() => {
+ const nextMode = resolveAuthModeForGatewayUrl(url, authMode);
+ if (nextMode === authMode) {
+ return;
+ }
+
+ setAuthMode(nextMode);
+ setAuthModeTouched(false);
+ setPairingAttempted(false);
+ setPairingCompletionPending(false);
+ setPairingSucceededWithoutDeviceToken(false);
+ setSaveFeedback(null);
+ }, [authMode, url]);
useEffect(() => {
setAdvancedTimeoutMs(String(advancedConnectionConfig.timeoutMs));
@@ -574,7 +593,16 @@ export function OpenClawConfigModule() {
{!url && {t('config.connection.urlRequired')}
}
- {url && (pairingStatusLoading || pairingStatus) ? (
+ {url && !pairingUiAllowed ? (
+
+
+ {t('setup.pairing.loopbackTitle')}
+
+
+ {t('setup.pairing.loopbackDesc')}
+
+
+ ) : url && (pairingStatusLoading || pairingStatus) ? (
@@ -697,6 +725,7 @@ export function OpenClawConfigModule() {
) : null}
+ {pairingUiAllowed ? (
{t('setup.pairing.knownTitle')}
@@ -739,6 +768,7 @@ export function OpenClawConfigModule() {
)}
+ ) : null}
) : null}
@@ -810,7 +840,9 @@ export function OpenClawConfigModule() {
)}
- {authMode === 'paired_device' && pairedDeviceAvailable ? (
+ {!pairingUiAllowed ? (
+
{t('setup.pairing.loopbackDesc')}
+ ) : authMode === 'paired_device' && pairedDeviceAvailable ? (
{t('setup.auth.pairedDeviceHint')}
) : !pairedDeviceAvailable ? (
{t('setup.pairing.pairedDeviceDisabled')}
diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx
index 6e3e3c3..55fc99a 100644
--- a/src/app/components/setup/SetupWizard.tsx
+++ b/src/app/components/setup/SetupWizard.tsx
@@ -5,6 +5,10 @@ import { CheckCircle2, XCircle, RefreshCw, Server, Shield, Globe, TerminalSquare
import { useI18n, LANGUAGES } from '../../contexts/I18nContext';
import { useTheme } from 'next-themes';
import appLogo from '../../../assets/270226c058e3f12ad7bb9e96e3b029bc0e2c0461.png';
+import {
+ resolveAuthModeForGatewayUrl,
+ shouldAllowPairingUiForGatewayUrl,
+} from '../../contexts/openClawConnectionPolicy';
import {
resolveOpenClawPairingFollowup,
resolveOpenClawStartPairingTransition,
@@ -54,10 +58,11 @@ export function SetupWizard() {
const pairingBootstrapTokenInputRef = useRef
(null);
const shouldShowWizard = isSetupWizardOpen || (!isConfigured && !hasSkippedSetup);
+ const pairingUiAllowed = shouldAllowPairingUiForGatewayUrl(url);
const isPairingRequired = lastError?.code === 'PAIRING_REQUIRED';
const isTokenMismatch = lastError?.code === 'AUTH_TOKEN_MISMATCH';
const pairedReady = pairingStatus?.pairedReady ?? false;
- const pairedDeviceAvailable = pairedReady;
+ const pairedDeviceAvailable = pairingUiAllowed && pairedReady;
const pairingFollowup = resolveOpenClawPairingFollowup({
pairedReady,
pairingAttempted,
@@ -91,7 +96,12 @@ export function SetupWizard() {
setStep(1);
setUrl(gatewayUrl || DEFAULT_GATEWAY_URL);
- setAuthMode(savedAuthMode === 'paired_device' ? 'token' : savedAuthMode);
+ setAuthMode(
+ resolveAuthModeForGatewayUrl(
+ gatewayUrl || DEFAULT_GATEWAY_URL,
+ savedAuthMode,
+ ),
+ );
setAuthSecret(savedAuthSecret);
setIsTesting(false);
setIsSaving(false);
@@ -106,7 +116,7 @@ export function SetupWizard() {
}, [shouldShowWizard, gatewayUrl, savedAuthMode, savedAuthSecret]);
useEffect(() => {
- if (!shouldShowWizard || !url.trim()) {
+ if (!shouldShowWizard || !url.trim() || !pairingUiAllowed) {
setPairingStatus(null);
setPairingStatusLoading(false);
return;
@@ -137,7 +147,21 @@ export function SetupWizard() {
cancelled = true;
window.clearTimeout(timer);
};
- }, [authModeTouched, shouldShowWizard, url]);
+ }, [authModeTouched, pairingUiAllowed, shouldShowWizard, url]);
+
+ useEffect(() => {
+ const nextMode = resolveAuthModeForGatewayUrl(url, authMode);
+ if (nextMode === authMode) {
+ return;
+ }
+
+ setAuthMode(nextMode);
+ setAuthModeTouched(false);
+ setPairingAttempted(false);
+ setPairingCompletionPending(false);
+ setPairingSucceededWithoutDeviceToken(false);
+ setPairingActionHint(null);
+ }, [authMode, url]);
const handleThemeToggle = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
@@ -514,7 +538,16 @@ export function SetupWizard() {
)}
- {url && (pairingStatusLoading || pairingStatus) ? (
+ {url && !pairingUiAllowed ? (
+
+
+ {t('setup.pairing.loopbackTitle')}
+
+
+ {t('setup.pairing.loopbackDesc')}
+
+
+ ) : url && (pairingStatusLoading || pairingStatus) ? (
{t('setup.pairing.statusTitle')}
@@ -651,7 +684,9 @@ export function SetupWizard() {
)}
- {authMode === 'paired_device' && pairedDeviceAvailable ? (
+ {!pairingUiAllowed ? (
+
{t('setup.pairing.loopbackDesc')}
+ ) : authMode === 'paired_device' && pairedDeviceAvailable ? (
{t('setup.auth.pairedDeviceHint')}
) : !pairedDeviceAvailable ? (
{t('setup.pairing.pairedDeviceDisabled')}
diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx
index 23fcb93..c5c371e 100644
--- a/src/app/contexts/I18nContext.tsx
+++ b/src/app/contexts/I18nContext.tsx
@@ -3798,6 +3798,16 @@ const BASE_DICT: Record
= {
"这次尝试只建立了共享 token 连接,但网关没有返回 device token。请停留在当前步骤,不要继续下一页。如果宿主机执行 `openclaw devices approve --latest` 后显示 `no pending device`,说明这次请求根本没有进入待批准配对队列。",
"這次嘗試只建立了共享 token 連線,但網關沒有回傳 device token。請停留在目前步驟,不要繼續下一頁。如果主機執行 `openclaw devices approve --latest` 後顯示 `no pending device`,表示這次請求根本沒有進入待批准配對佇列。",
],
+ "setup.pairing.loopbackTitle": [
+ "Loopback pairing is unavailable here",
+ "当前不支持本机 loopback 配对",
+ "目前不支援本機 loopback 配對",
+ ],
+ "setup.pairing.loopbackDesc": [
+ "For `127.0.0.1` / `localhost`, use Gateway Token or Password for local access. If you need device pairing, switch this URL to a LAN IP first.",
+ "对于 `127.0.0.1` / `localhost`,请使用 Gateway Token 或 Password 做本地连接;如需设备配对,请先把当前地址切换为局域网 IP。",
+ "對於 `127.0.0.1` / `localhost`,請使用 Gateway Token 或 Password 做本機連線;如需裝置配對,請先把目前位址切換為區域網路 IP。",
+ ],
"memory.header.agents": ["Agents", "Agents", "Agents"],
"memory.overview.pending": [
"Overview is waiting for agent resolution and the first batch of memory data.",
diff --git a/src/app/contexts/openClawConnectionPolicy.test.ts b/src/app/contexts/openClawConnectionPolicy.test.ts
index 3cf1360..b628d83 100644
--- a/src/app/contexts/openClawConnectionPolicy.test.ts
+++ b/src/app/contexts/openClawConnectionPolicy.test.ts
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import {
isLoopbackGatewayUrl,
+ resolveAuthModeForGatewayUrl,
resolvePersistedAuthModeAfterConnect,
+ shouldAllowPairingUiForGatewayUrl,
shouldRetryWithPairedDeviceOnLocalGateway,
} from './openClawConnectionPolicy';
@@ -18,6 +20,19 @@ describe('openClawConnectionPolicy', () => {
expect(isLoopbackGatewayUrl('not-a-url')).toBe(false);
});
+ it('disables pairing ui for loopback gateway urls and keeps it for lan/remote urls', () => {
+ expect(shouldAllowPairingUiForGatewayUrl('http://127.0.0.1:18789')).toBe(false);
+ expect(shouldAllowPairingUiForGatewayUrl('http://localhost:18789')).toBe(false);
+ expect(shouldAllowPairingUiForGatewayUrl('http://[::1]:18789')).toBe(false);
+ expect(shouldAllowPairingUiForGatewayUrl('http://192.168.1.112:18789')).toBe(true);
+ });
+
+ it('coerces paired_device auth back to token on loopback urls', () => {
+ expect(resolveAuthModeForGatewayUrl('http://127.0.0.1:18789', 'paired_device')).toBe('token');
+ expect(resolveAuthModeForGatewayUrl('http://localhost:18789', 'password')).toBe('password');
+ expect(resolveAuthModeForGatewayUrl('http://192.168.1.112:18789', 'paired_device')).toBe('paired_device');
+ });
+
it('retries local token auth mismatches with paired device mode', () => {
expect(
shouldRetryWithPairedDeviceOnLocalGateway('http://127.0.0.1:18789', 'token', {
diff --git a/src/app/contexts/openClawConnectionPolicy.ts b/src/app/contexts/openClawConnectionPolicy.ts
index c2fab89..65561e6 100644
--- a/src/app/contexts/openClawConnectionPolicy.ts
+++ b/src/app/contexts/openClawConnectionPolicy.ts
@@ -33,6 +33,18 @@ export function isLoopbackGatewayUrl(url: string) {
}
}
+export function shouldAllowPairingUiForGatewayUrl(url: string) {
+ return !isLoopbackGatewayUrl(url);
+}
+
+export function resolveAuthModeForGatewayUrl(url: string, mode: AuthMode): AuthMode {
+ if (isLoopbackGatewayUrl(url) && mode === 'paired_device') {
+ return 'token';
+ }
+
+ return mode;
+}
+
export function shouldRetryWithPairedDeviceOnLocalGateway(
url: string,
mode: AuthMode,
From e0ad7128090c442148de77b14ef583f5631b4cc7 Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 13:41:27 +0800
Subject: [PATCH 06/17] =?UTF-8?q?fix(setup):=20=E4=BF=AE=E5=A4=8D=20loopba?=
=?UTF-8?q?ck=20token=20=E8=BF=9B=E5=85=A5=E5=BA=94=E7=94=A8=E5=9B=9E?=
=?UTF-8?q?=E5=BD=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
禁止 loopback 自动采纳 pairedReady,避免测试成功后清空 token 并在进入应用时触发 token required。
---
src/app/components/setup/SetupWizard.tsx | 5 +++--
src/app/components/setup/openClawPairingState.test.ts | 7 +++++++
src/app/components/setup/openClawPairingState.ts | 5 +++++
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx
index 55fc99a..8b94e96 100644
--- a/src/app/components/setup/SetupWizard.tsx
+++ b/src/app/components/setup/SetupWizard.tsx
@@ -10,6 +10,7 @@ import {
shouldAllowPairingUiForGatewayUrl,
} from '../../contexts/openClawConnectionPolicy';
import {
+ canAdoptPairedDeviceForGatewayUrl,
resolveOpenClawPairingFollowup,
resolveOpenClawStartPairingTransition,
shouldShowNoPendingPairingHint,
@@ -235,7 +236,7 @@ export function SetupWizard() {
setPairingStatusLoading(true);
try {
const next = await gatewayPairingStatusLookup(targetUrl);
- applyPairingStatus(next, true);
+ applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl));
} catch {
// Ignore refresh failures and keep the test result visible.
} finally {
@@ -275,7 +276,7 @@ export function SetupWizard() {
setPairingStatusLoading(true);
try {
const next = await gatewayPairingStatusLookup(targetUrl);
- applyPairingStatus(next, true);
+ applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl));
const transition = resolveOpenClawStartPairingTransition({
connectSucceeded: true,
pairedReady: next.pairedReady,
diff --git a/src/app/components/setup/openClawPairingState.test.ts b/src/app/components/setup/openClawPairingState.test.ts
index 96579c1..3201617 100644
--- a/src/app/components/setup/openClawPairingState.test.ts
+++ b/src/app/components/setup/openClawPairingState.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
+ canAdoptPairedDeviceForGatewayUrl,
resolveOpenClawPairingFollowup,
resolveOpenClawStartPairingTransition,
shouldShowNoPendingPairingHint,
@@ -119,4 +120,10 @@ describe("openClawPairingState", () => {
pairingSucceededWithoutDeviceToken: false,
});
});
+
+ it("does not allow paired-device adoption for loopback gateway urls", () => {
+ expect(canAdoptPairedDeviceForGatewayUrl("http://127.0.0.1:18789")).toBe(false);
+ expect(canAdoptPairedDeviceForGatewayUrl("http://localhost:18789")).toBe(false);
+ expect(canAdoptPairedDeviceForGatewayUrl("http://192.168.1.112:18789")).toBe(true);
+ });
});
diff --git a/src/app/components/setup/openClawPairingState.ts b/src/app/components/setup/openClawPairingState.ts
index 00f6d76..534f485 100644
--- a/src/app/components/setup/openClawPairingState.ts
+++ b/src/app/components/setup/openClawPairingState.ts
@@ -1,4 +1,5 @@
import type { GatewayErrorSummary } from "../../contexts/OpenClawContext";
+import { isLoopbackGatewayUrl } from "../../contexts/openClawConnectionPolicy";
export type OpenClawPairingFollowupKind =
| "none"
@@ -27,6 +28,10 @@ interface OpenClawStartPairingTransition {
pairingSucceededWithoutDeviceToken: boolean;
}
+export function canAdoptPairedDeviceForGatewayUrl(url: string) {
+ return !isLoopbackGatewayUrl(url);
+}
+
export function resolveOpenClawPairingFollowup({
pairedReady,
pairingAttempted,
From 30f3f19421c64e909b958d9e7876ab561f1c6dcf Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 14:59:39 +0800
Subject: [PATCH 07/17] =?UTF-8?q?fix(profile):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=A4=9A=E4=BC=9A=E8=AF=9D=E8=8A=82=E7=82=B9=E5=8F=AF=E8=A7=81?=
=?UTF-8?q?=E6=80=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Profile 节点列表改为基于 realNodes 展示,未加载 agent 的会话节点也可见并可切换为 active。
---
src/app/components/views/ProfileView.tsx | 40 ++++++++++++++-----
.../components/views/profileNodeState.test.ts | 30 ++++++++++++++
src/app/components/views/profileNodeState.ts | 40 +++++++++++++++++++
3 files changed, 99 insertions(+), 11 deletions(-)
diff --git a/src/app/components/views/ProfileView.tsx b/src/app/components/views/ProfileView.tsx
index 4a8d458..44bcb1b 100644
--- a/src/app/components/views/ProfileView.tsx
+++ b/src/app/components/views/ProfileView.tsx
@@ -52,6 +52,7 @@ import {
} from "../../contexts/OpenClawContext";
import { applyIdentityMetaToDocument } from "./profileIdentityDocument";
import {
+ buildVisibleProfileNodeEntries,
groupAgentsByNode,
resolveSelectedAgentIdForNode,
resolveSelectedProfileNodeName,
@@ -2169,6 +2170,7 @@ export function ProfileView() {
isConnected,
connectedOrigin,
grantedScopes,
+ setActiveSession,
} = useOpenClaw();
const MOCK_AGENTS_BACKUP: DisplayAgent[] = [
@@ -2617,9 +2619,22 @@ export function ProfileView() {
() => groupAgentsByNode(displayAgents),
[displayAgents],
);
- const nodeEntries = useMemo(() => Object.entries(groupedAgents), [groupedAgents]);
+ const nodeEntries = useMemo(
+ () =>
+ hasRealAgents
+ ? buildVisibleProfileNodeEntries(realNodes, groupedAgents)
+ : Object.entries(groupedAgents).map(([nodeName, agents]) => ({
+ id: nodeName,
+ name: nodeName,
+ status: "online" as const,
+ sessionId: undefined,
+ isActive: undefined,
+ agentCount: agents.length,
+ })),
+ [groupedAgents, hasRealAgents, realNodes],
+ );
const nodeNames = useMemo(
- () => nodeEntries.map(([nodeName]) => nodeName),
+ () => nodeEntries.map((entry) => entry.name),
[nodeEntries],
);
const nodeCount = nodeNames.length;
@@ -2635,14 +2650,9 @@ export function ProfileView() {
}
}, [nodeNames, selectedNodeName]);
- useEffect(() => {
- if (activeAgent?.node && activeAgent.node !== selectedNodeName) {
- setSelectedNodeName(activeAgent.node);
- }
- }, [activeAgent?.node, selectedNodeName]);
-
const handleNodeSelect = (nodeName: string) => {
setSelectedNodeName(nodeName);
+ const selectedNodeEntry = nodeEntries.find((entry) => entry.name === nodeName);
const nextSelectedAgentId = resolveSelectedAgentIdForNode(
selectedAgentId,
groupedAgents[nodeName] ?? [],
@@ -2651,6 +2661,14 @@ export function ProfileView() {
if (nextSelectedAgentId !== selectedAgentId) {
setSelectedAgentId(nextSelectedAgentId);
}
+
+ if (
+ selectedNodeEntry?.sessionId &&
+ hasRealAgents &&
+ !selectedNodeEntry.isActive
+ ) {
+ void setActiveSession(selectedNodeEntry.sessionId);
+ }
};
const activeNodeAgents = selectedNodeName
@@ -2668,9 +2686,9 @@ export function ProfileView() {
disabled={nodeCount <= 1}
className="w-full cursor-pointer appearance-none rounded-2xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-10 text-sm font-medium text-slate-700 outline-none transition-colors focus:border-sky-400 focus:bg-white dark:border-slate-800 dark:bg-slate-950/40 dark:text-slate-200 dark:focus:border-sky-500 dark:focus:bg-slate-900 disabled:cursor-default disabled:opacity-70"
>
- {nodeEntries.map(([nodeName, agents]) => (
-
))}
diff --git a/src/app/components/views/profileNodeState.test.ts b/src/app/components/views/profileNodeState.test.ts
index 0dcb240..216551c 100644
--- a/src/app/components/views/profileNodeState.test.ts
+++ b/src/app/components/views/profileNodeState.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
+ buildVisibleProfileNodeEntries,
groupAgentsByNode,
resolveSelectedAgentIdForNode,
resolveSelectedProfileNodeName,
@@ -52,4 +53,33 @@ describe("profileNodeState", () => {
expect(resolveSelectedAgentIdForNode("agent-a", [])).toBe("");
});
+
+ it("keeps real nodes visible even when no agents are currently loaded for that session", () => {
+ const visibleNodes = buildVisibleProfileNodeEntries([
+ {
+ id: "gateway:ws://127.0.0.1:18789",
+ name: "OpenClaw Local",
+ status: "online" as const,
+ sessionId: "ws://127.0.0.1:18789",
+ isActive: true,
+ },
+ {
+ id: "gateway:ws://192.168.1.112:18789",
+ name: "OpenClaw 192.168.1.112:18789",
+ status: "online" as const,
+ sessionId: "ws://192.168.1.112:18789",
+ isActive: false,
+ },
+ ], groupAgentsByNode([
+ { id: "agent-a", node: "OpenClaw Local" },
+ ]));
+
+ expect(visibleNodes.map((entry) => entry.name)).toEqual([
+ "OpenClaw Local",
+ "OpenClaw 192.168.1.112:18789",
+ ]);
+ expect(visibleNodes[0]?.agentCount).toBe(1);
+ expect(visibleNodes[1]?.agentCount).toBe(0);
+ expect(visibleNodes[1]?.sessionId).toBe("ws://192.168.1.112:18789");
+ });
});
diff --git a/src/app/components/views/profileNodeState.ts b/src/app/components/views/profileNodeState.ts
index db3c0ca..25eb533 100644
--- a/src/app/components/views/profileNodeState.ts
+++ b/src/app/components/views/profileNodeState.ts
@@ -1,3 +1,5 @@
+import type { Node } from "../../contexts/OpenClawContext";
+
export function groupAgentsByNode(agents: T[]) {
return agents.reduce(
(acc, agent) => {
@@ -32,3 +34,41 @@ export function resolveSelectedAgentIdForNode(
return nodeAgents[0]?.id ?? "";
}
+
+export interface ProfileNodeEntry {
+ id: string;
+ name: string;
+ status: Node["status"];
+ sessionId?: string;
+ isActive?: boolean;
+ agentCount: number;
+}
+
+export function buildVisibleProfileNodeEntries(
+ nodes: Pick[],
+ groupedAgents: Record,
+) {
+ const entries: ProfileNodeEntry[] = nodes.map((node) => ({
+ id: node.id,
+ name: node.name,
+ status: node.status,
+ sessionId: node.sessionId,
+ isActive: node.isActive,
+ agentCount: groupedAgents[node.name]?.length ?? 0,
+ }));
+
+ for (const [nodeName, nodeAgents] of Object.entries(groupedAgents)) {
+ if (entries.some((entry) => entry.name === nodeName)) {
+ continue;
+ }
+
+ entries.push({
+ id: nodeName,
+ name: nodeName,
+ status: "online",
+ agentCount: nodeAgents.length,
+ });
+ }
+
+ return entries;
+}
From a3863e84c7a8664c1269d8cbd1aff185912d8a66 Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 15:16:38 +0800
Subject: [PATCH 08/17] =?UTF-8?q?fix(config):=20=E5=AF=B9=E9=BD=90=20loopb?=
=?UTF-8?q?ack=20token=20=E5=9B=9E=E5=BD=92=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
配置页同步禁止 loopback 自动采纳 pairedReady,避免本地 token 连接被错误清空。
---
src/app/components/setup/OpenClawConfigModule.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx
index 3dbf257..b353a2f 100644
--- a/src/app/components/setup/OpenClawConfigModule.tsx
+++ b/src/app/components/setup/OpenClawConfigModule.tsx
@@ -13,6 +13,7 @@ import {
type OpenClawConfigSectionId,
} from './openClawConfigSectionState';
import {
+ canAdoptPairedDeviceForGatewayUrl,
resolveOpenClawPairingFollowup,
resolveOpenClawStartPairingTransition,
shouldShowNoPendingPairingHint,
@@ -192,7 +193,7 @@ export function OpenClawConfigModule() {
setPairingStatusLoading(true);
try {
const next = await gatewayPairingStatusLookup(targetUrl);
- applyPairingStatus(next, true);
+ applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl));
if (next.pairedReady && usedBootstrapToken) {
setSaveFeedback({
@@ -245,7 +246,7 @@ export function OpenClawConfigModule() {
setPairingStatusLoading(true);
try {
const next = await gatewayPairingStatusLookup(targetUrl);
- applyPairingStatus(next, true);
+ applyPairingStatus(next, canAdoptPairedDeviceForGatewayUrl(targetUrl));
const transition = resolveOpenClawStartPairingTransition({
connectSucceeded: true,
pairedReady: next.pairedReady,
From bcbdc37adf0c7ccccb77f49907ff19e60ccfede2 Mon Sep 17 00:00:00 2001
From: milome
Date: Fri, 17 Apr 2026 15:32:43 +0800
Subject: [PATCH 09/17] =?UTF-8?q?fix(evolution):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E8=8A=82=E7=82=B9=E5=88=87=E6=8D=A2=E6=9C=AA=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E4=BC=9A=E8=AF=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
进化页选择其他节点时同步切换 active session,确保 loopback 节点也能显示对应 agents。
---
src/app/components/views/EvolutionView.tsx | 15 +++++++--
.../views/evolutionTargetState.test.ts | 31 +++++++++++++++++--
.../components/views/evolutionTargetState.ts | 20 ++++++++++++
3 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/src/app/components/views/EvolutionView.tsx b/src/app/components/views/EvolutionView.tsx
index 8c2823a..e367b57 100644
--- a/src/app/components/views/EvolutionView.tsx
+++ b/src/app/components/views/EvolutionView.tsx
@@ -45,6 +45,7 @@ import { buildEvolutionAuditReportMarkdown } from "./evolutionAuditReport";
import { formatKnowledgePackExample, parseKnowledgeInjectionPack } from "./evolutionKnowledgePack";
import {
buildEvolutionTargetNodeEntries,
+ resolveEvolutionSessionIdToActivate,
resolveSelectedEvolutionAgentId,
resolveSelectedEvolutionNodeId,
} from "./evolutionTargetState";
@@ -180,7 +181,7 @@ function mapRuntimePhaseToStep(phase: EvolutionOperationStatusSnapshot["phase"])
export function EvolutionView() {
const { lang, t } = useI18n();
- const { nodes, agents, isConnected } = useOpenClaw();
+ const { nodes, agents, isConnected, setActiveSession } = useOpenClaw();
const RECENT_HISTORY_COLLAPSED_COUNT = 3;
const buildCustomTemplateExample = () =>
`{\n "mode": "append_block",\n "title": "${t("evo.custom.example.blockTitle")}",\n "content": "${t("evo.custom.example.blockContent")}"\n}`;
@@ -869,7 +870,17 @@ export function EvolutionView() {
@@ -359,7 +411,7 @@ export function MemoryKnowledgePanel({
/>
0 ? knowledgeModel.sources.join(", ") : t("memory.knowledge.sourcesEmpty")}
+ value={readableSources.length > 0 ? readableSources.join(", ") : t("memory.knowledge.sourcesEmpty")}
/>
- {memoryResult?.diagnostics?.sources.join(", ") || t("memory.knowledge.sourcesEmpty")}
+ {readableSources.join(", ") || t("memory.knowledge.sourcesEmpty")}
@@ -414,6 +466,100 @@ export function MemoryKnowledgePanel({
{t("memory.knowledge.guardrailDesc")}
+
+
+
+
+
+ {t("memory.knowledge.extraPathsGuideTitle")}
+
+
+ {t("memory.knowledge.extraPathsGuideDesc")}
+
+
+
{t("memory.knowledge.incrementalOnlyTitle")}
+
{t("memory.knowledge.incrementalOnlyDesc")}
+
+
+
+
{t("memory.knowledge.extraPathsDoTitle")}
+
{t("memory.knowledge.extraPathsDoExample")}
+
+
+
{t("memory.knowledge.extraPathsDontTitle")}
+
{t("memory.knowledge.extraPathsDontExample")}
+
+
+
+
+
+
+
+ {t("memory.knowledge.howToTitle")}
+
+
+ {[
+ t("memory.knowledge.howToStep1"),
+ t("memory.knowledge.howToStep2"),
+ t("memory.knowledge.howToStep3"),
+ t("memory.knowledge.howToStep4"),
+ ].map((step, index) => (
+
+
+ {index + 1}
+
+
{step}
+
+ ))}
+
+
+
+
+
+ {knowledgeModel.sections.map((section) => (
+
+
+ {t(section.titleKey)}
+
+
+ {t(section.descriptionKey)}
+
+ {section.entries.length > 0 ? (
+
+ {section.entries.map((entry) => (
+
+
+
+
+ {describeKnowledgeEntry(entry.label)}
+
+ {entry.path && describeKnowledgeEntry(entry.label) !== entry.path ? (
+
+ {entry.path}
+
+ ) : null}
+
+
+ {t(`memory.knowledge.status.${entry.status}`)}
+
+
+ {entry.note ? (
+
+
+
{describeKnowledgeNote(entry.note)}
+
+ ) : null}
+
+ ))}
+
+ ) : (
+
+ {t("memory.knowledge.sectionEmpty")}
+
+ )}
+
+ ))}
+
{t("memory.knowledge.configActions")}
@@ -458,12 +604,16 @@ export function MemoryKnowledgePanel({
{statusSummary.commandGuide}
>
) : null}
+
+
{t("memory.knowledge.incrementalOnlyInline")}
+
{fieldErrors.reindex ?
: null}
{t("memory.knowledge.externalPaths")}
+
{t("memory.knowledge.externalPathsDesc")}
+
+ {t("memory.knowledge.pathInputHint")}
+
{knowledgeModel.extraPaths.length > 0 ? (
{knowledgeModel.extraPaths.map((path) => (
@@ -523,7 +676,9 @@ export function MemoryKnowledgePanel({
{(["memory", "sessions"] as const).map((source) => (
@@ -1334,7 +1332,7 @@ export function MemoryKnowledgePanel({
{configFeedback ?
: null}
- {reindexFeedback ?
: null}
+ {reindexFeedback && !reindexActivity?.syncIssue ?
: null}
);
diff --git a/src/app/components/views/MemoryView.tsx b/src/app/components/views/MemoryView.tsx
index 2ece733..162a631 100644
--- a/src/app/components/views/MemoryView.tsx
+++ b/src/app/components/views/MemoryView.tsx
@@ -293,6 +293,15 @@ function openTargetForResource(kind: "document" | "timeline" | "external_source"
return "overview" as const;
}
+async function withTimeout
(promise: Promise, timeoutMs: number) {
+ return await Promise.race([
+ promise,
+ new Promise((resolve) => {
+ window.setTimeout(() => resolve(null), timeoutMs);
+ }),
+ ]);
+}
+
export function MemoryView() {
const { t } = useI18n();
const { nodes, agents, grantedScopes, isConnected, connectedOrigin, setActiveSession } = useOpenClaw();
@@ -752,21 +761,43 @@ export function MemoryView() {
}
const [memory, status, runtime] = await Promise.all([
- gatewayAgentMemoryGet(selectedAgentId, selectedSessionId),
- gatewayAgentMemoryStatus(selectedAgentId, selectedSessionId).catch(() => null),
+ withTimeout(
+ gatewayAgentMemoryGet(selectedAgentId, selectedSessionId),
+ 5000,
+ ),
+ withTimeout(
+ gatewayAgentMemoryStatus(selectedAgentId, selectedSessionId).catch(() => null),
+ 4000,
+ ),
isLocalGatewaySession
- ? gatewayAgentMemoryRuntimeStatus(selectedAgentId, selectedSessionId).catch(() => null)
+ ? withTimeout(
+ gatewayAgentMemoryRuntimeStatus(selectedAgentId, selectedSessionId).catch(() => null),
+ 3000,
+ )
: Promise.resolve(null),
]);
- setMemoryResult(memory);
- setDrafts(createMemoryDrafts(memory));
- setMemoryStatus(status);
- setMemoryRuntimeStatus(runtime);
+ const nextMemory = memory ?? memoryResult;
+ const nextStatus = status ?? memoryStatus;
+ const nextRuntime = runtime ?? memoryRuntimeStatus;
+
+ if (nextMemory) {
+ setMemoryResult(nextMemory);
+ if (memory) {
+ setDrafts(createMemoryDrafts(nextMemory));
+ }
+ }
+ setMemoryStatus(nextStatus);
+ setMemoryRuntimeStatus(nextRuntime);
+
+ if (!nextMemory) {
+ return null;
+ }
+
return {
- memoryResult: memory,
- memoryStatus: status,
- runtimeStatus: runtime,
+ memoryResult: nextMemory,
+ memoryStatus: nextStatus,
+ runtimeStatus: nextRuntime,
};
};
From c093b24ad75bfe7b6bb242080cf33eccdfabd7b7 Mon Sep 17 00:00:00 2001
From: milome
Date: Mon, 20 Apr 2026 01:24:48 +0800
Subject: [PATCH 16/17] =?UTF-8?q?fix(memory):=20=E4=BF=9D=E6=8C=81?=
=?UTF-8?q?=E8=B7=A8=E6=A0=87=E7=AD=BE=E7=B4=A2=E5=BC=95=E4=BB=BB=E5=8A=A1?=
=?UTF-8?q?=E7=8A=B6=E6=80=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../views/MemoryKnowledgePanel.test.tsx | 169 ++++--
.../components/views/MemoryKnowledgePanel.tsx | 518 +-----------------
.../MemoryView.reindexPersistence.test.tsx | 217 ++++++++
src/app/components/views/MemoryView.tsx | 385 ++++++++++++-
.../views/memoryKnowledgeReindexState.ts | 23 +
5 files changed, 760 insertions(+), 552 deletions(-)
create mode 100644 src/app/components/views/MemoryView.reindexPersistence.test.tsx
diff --git a/src/app/components/views/MemoryKnowledgePanel.test.tsx b/src/app/components/views/MemoryKnowledgePanel.test.tsx
index bce2a48..462fa84 100644
--- a/src/app/components/views/MemoryKnowledgePanel.test.tsx
+++ b/src/app/components/views/MemoryKnowledgePanel.test.tsx
@@ -17,7 +17,6 @@ vi.mock("./MemoryMindMapPanel", () => ({
}));
vi.mock("./memoryKnowledgeActions", () => ({
- runExternalKnowledgeReindex: vi.fn(),
setExternalKnowledgePaths: vi.fn(),
setExternalKnowledgeSources: vi.fn(),
setSessionMemoryEnabled: vi.fn(),
@@ -25,7 +24,6 @@ vi.mock("./memoryKnowledgeActions", () => ({
import { MemoryKnowledgePanel } from "./MemoryKnowledgePanel";
import {
- runExternalKnowledgeReindex,
setExternalKnowledgePaths,
setExternalKnowledgeSources,
setSessionMemoryEnabled,
@@ -100,6 +98,13 @@ const baseProps = {
runtimeStatus: null,
}),
onOpenDiagnostics: vi.fn(),
+ reindexActivity: null,
+ reindexDetailsExpanded: true,
+ reindexFeedback: null,
+ isReindexBusy: false,
+ onToggleReindexDetails: vi.fn(),
+ onRunReindex: vi.fn().mockResolvedValue(undefined),
+ onRunAutoReindex: vi.fn().mockResolvedValue(undefined),
};
describe("MemoryKnowledgePanel", () => {
@@ -125,7 +130,6 @@ describe("MemoryKnowledgePanel", () => {
it("enables session memory and auto-adds sessions source", async () => {
vi.mocked(setSessionMemoryEnabled).mockResolvedValue({ kind: "set_session_memory", stdout: "ok" });
vi.mocked(setExternalKnowledgeSources).mockResolvedValue({ kind: "set_sources", stdout: "ok" });
- vi.mocked(runExternalKnowledgeReindex).mockResolvedValue({ kind: "reindex", stdout: "reindexed" });
render();
const user = userEvent.setup();
@@ -135,12 +139,12 @@ describe("MemoryKnowledgePanel", () => {
await waitFor(() => {
expect(setSessionMemoryEnabled).toHaveBeenCalledWith(true, t, "session-local");
expect(setExternalKnowledgeSources).toHaveBeenCalledWith(["memory", "sessions"], t, "session-local");
+ expect(baseProps.onRunAutoReindex).toHaveBeenCalled();
});
});
it("asks for confirmation before removing extra path", async () => {
vi.mocked(setExternalKnowledgePaths).mockResolvedValue({ kind: "set_extra_paths", stdout: "ok" });
- vi.mocked(runExternalKnowledgeReindex).mockResolvedValue({ kind: "reindex", stdout: "reindexed" });
const user = userEvent.setup();
render();
@@ -149,53 +153,88 @@ describe("MemoryKnowledgePanel", () => {
expect(globalThis.confirm).toHaveBeenCalled();
await waitFor(() => {
expect(setExternalKnowledgePaths).toHaveBeenCalledWith([], t, "session-local");
+ expect(baseProps.onRunAutoReindex).toHaveBeenCalled();
});
});
- it("shows reindex activity while the command is running", async () => {
- let resolveReindex: ((value: { kind: "reindex"; stdout: string }) => void) | undefined;
- vi.mocked(runExternalKnowledgeReindex).mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveReindex = resolve as typeof resolveReindex;
- }),
+ it("renders lifted reindex activity state from parent props", () => {
+ render(
+ ,
);
- render();
- const user = userEvent.setup();
- await user.click(screen.getByText("memory.knowledge.reindexNow"));
-
expect(screen.getByText("memory.knowledge.reindexLive.title")).toBeTruthy();
- expect(screen.getByText("memory.knowledge.reindexLive.event.submitted")).toBeTruthy();
-
- resolveReindex?.({ kind: "reindex", stdout: "ok" });
-
- await waitFor(() => {
- expect(runExternalKnowledgeReindex).toHaveBeenCalledWith(
- "agent-main",
- "incremental",
- t,
- "session-local",
- );
- });
+ expect(screen.getByText("memory.knowledge.reindexLive.taskbarTitle")).toBeTruthy();
});
it("shows retry and diagnostics actions after a reindex failure", async () => {
- vi.mocked(runExternalKnowledgeReindex).mockRejectedValueOnce({
- kind: "reindex",
- code: "unknown",
- message: "memory.knowledge.error.reindexFailed",
- rawMessage: "boom",
- });
-
- render();
+ render(
+ ,
+ );
const user = userEvent.setup();
- await user.click(screen.getByText("memory.knowledge.reindexNow"));
- await waitFor(() => {
- expect(screen.getByText("memory.knowledge.reindexLive.retry")).toBeTruthy();
- expect(screen.getByText("memory.knowledge.reindexLive.openDiagnostics")).toBeTruthy();
- });
+ expect(screen.getByText("memory.knowledge.reindexLive.retry")).toBeTruthy();
+ expect(screen.getByText("memory.knowledge.reindexLive.openDiagnostics")).toBeTruthy();
await user.click(screen.getByText("memory.knowledge.reindexLive.openDiagnostics"));
@@ -203,21 +242,41 @@ describe("MemoryKnowledgePanel", () => {
});
it("keeps only one persistent reindex error in the panel", async () => {
- vi.mocked(runExternalKnowledgeReindex).mockRejectedValueOnce({
- kind: "reindex",
- code: "unknown",
- message: "boom",
- rawMessage: "boom",
- });
-
- render();
- const user = userEvent.setup();
- await user.click(screen.getByText("memory.knowledge.reindexNow"));
-
- await waitFor(() => {
- expect(screen.getAllByText(/boom/)).toHaveLength(1);
- });
+ render(
+ ,
+ );
- expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByText(/boom/)).toHaveLength(1);
+ expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(0);
});
});
diff --git a/src/app/components/views/MemoryKnowledgePanel.tsx b/src/app/components/views/MemoryKnowledgePanel.tsx
index 9e22322..3a08978 100644
--- a/src/app/components/views/MemoryKnowledgePanel.tsx
+++ b/src/app/components/views/MemoryKnowledgePanel.tsx
@@ -1,5 +1,5 @@
import { Activity, CheckCircle2, ChevronDown, ChevronUp, Copy, FolderTree, Info, Link2, ListChecks, Loader2, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react";
-import { startTransition, useEffect, useMemo, useRef, useState } from "react";
+import { startTransition, useEffect, useState } from "react";
import { toast } from "sonner";
import type {
GatewayAgentMemoryResult,
@@ -11,7 +11,6 @@ import type { MemoryExternalSourceItem } from "./memoryState";
import { buildExternalKnowledgeViewModel, isBlockedExternalKnowledgePath } from "./memoryKnowledgeState";
import { buildMemoryConfigStatusSummary, memoryConfigBridgeMessageKey, memoryConfigStatusMessageKey, memoryReindexModeMessageKey } from "./memoryConfigStatus";
import {
- runExternalKnowledgeReindex,
setExternalKnowledgePaths,
setExternalKnowledgeSources,
setSessionMemoryEnabled,
@@ -19,13 +18,9 @@ import {
type MemoryKnowledgeActionKind,
} from "./memoryKnowledgeActions";
import {
- captureMemoryKnowledgeReindexSnapshot,
describeMemoryKnowledgeReindexDelta,
- hasMemoryKnowledgeReindexProgress,
- isMemoryKnowledgeReindexSettled,
type MemoryKnowledgeRefreshResult,
- type MemoryKnowledgeReindexPhase,
- type MemoryKnowledgeReindexSnapshot,
+ type MemoryKnowledgeReindexActivityState,
} from "./memoryKnowledgeReindexState";
import { MemoryMindMapPanel } from "./MemoryMindMapPanel";
import { ArchiveActionButton, ArchiveNotice, ArchiveSectionCard, ArchiveStatCard, type ArchiveTone } from "./memoryArchiveUi";
@@ -35,30 +30,6 @@ type FieldErrorState = {
extraPath?: string | null;
sessionMemory?: string | null;
sources?: string | null;
- reindex?: string | null;
-};
-
-type ReindexTimelineEntry = {
- id: string;
- tone: "info" | "warn" | "error";
- title: string;
- detail?: string | null;
- atMs: number;
-};
-
-type ReindexActivityState = {
- phase: MemoryKnowledgeReindexPhase;
- startedAtMs: number;
- finishedAtMs: number | null;
- polls: number;
- afterCommandPolls: number;
- lastPolledAtMs: number | null;
- before: MemoryKnowledgeReindexSnapshot;
- latest: MemoryKnowledgeReindexSnapshot;
- commandStdout: string | null;
- syncIssue: string | null;
- progressObserved: boolean;
- entries: ReindexTimelineEntry[];
};
type MemoryKnowledgePanelProps = {
@@ -86,6 +57,13 @@ type MemoryKnowledgePanelProps = {
openHint: string | null;
onRefreshKnowledge: () => Promise;
onOpenDiagnostics: () => void;
+ reindexActivity: MemoryKnowledgeReindexActivityState | null;
+ reindexDetailsExpanded: boolean;
+ reindexFeedback: string | null;
+ isReindexBusy: boolean;
+ onToggleReindexDetails: () => void;
+ onRunReindex: () => Promise;
+ onRunAutoReindex: () => Promise;
};
export function MemoryKnowledgePanel({
@@ -106,6 +84,13 @@ export function MemoryKnowledgePanel({
openHint,
onRefreshKnowledge,
onOpenDiagnostics,
+ reindexActivity,
+ reindexDetailsExpanded,
+ reindexFeedback,
+ isReindexBusy,
+ onToggleReindexDetails,
+ onRunReindex,
+ onRunAutoReindex,
}: MemoryKnowledgePanelProps) {
const tonePalette = resolveViewToneClasses(tone);
const toneClasses = {
@@ -125,14 +110,7 @@ export function MemoryKnowledgePanel({
const [fieldErrors, setFieldErrors] = useState({});
const [savingAction, setSavingAction] = useState(null);
const [configFeedback, setConfigFeedback] = useState(null);
- const [reindexFeedback, setReindexFeedback] = useState(null);
- const [reindexActivity, setReindexActivity] = useState(null);
- const [reindexDetailsExpanded, setReindexDetailsExpanded] = useState(true);
const [nowMs, setNowMs] = useState(() => Date.now());
- const activeReindexTokenRef = useRef(0);
- const pollTimerRef = useRef(null);
- const pollInFlightRef = useRef(false);
- const commandCompletedRef = useRef(false);
const statusSummary = buildMemoryConfigStatusSummary({
selectedAgentId,
isLocalGatewaySession,
@@ -140,14 +118,6 @@ export function MemoryKnowledgePanel({
memoryStatus,
runtimeStatus,
});
- const currentReindexSnapshot = useMemo(
- () =>
- captureMemoryKnowledgeReindexSnapshot({
- statusSummary,
- runtimeStatus,
- }),
- [statusSummary, runtimeStatus],
- );
const autoReindexEnabled = statusSummary.reindexMode === "auto";
const externalEntryCount = knowledgeModel.sections.reduce((count, section) => count + section.entries.length, 0);
const readableSources = knowledgeModel.sources.map((source) =>
@@ -184,15 +154,6 @@ export function MemoryKnowledgePanel({
}
};
- const stopReindexPolling = () => {
- if (pollTimerRef.current !== null) {
- window.clearInterval(pollTimerRef.current);
- pollTimerRef.current = null;
- }
- pollInFlightRef.current = false;
- };
-
- const isReindexBusy = savingAction === "reindex" || (reindexActivity !== null && reindexActivity.finishedAtMs === null);
const controlsDisabled = savingAction !== null || isReindexBusy;
const showReindexRecoveryActions =
reindexActivity?.phase === "warning" || reindexActivity?.phase === "failed";
@@ -207,18 +168,6 @@ export function MemoryKnowledgePanel({
return Math.round(deltaMs / 1000);
};
- const createReindexEntry = (
- tone: ReindexTimelineEntry["tone"],
- title: string,
- detail?: string | null,
- ): ReindexTimelineEntry => ({
- id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- tone,
- title,
- detail,
- atMs: Date.now(),
- });
-
useEffect(() => {
if (!knowledgeModel.localWritable) {
setFieldErrors({});
@@ -234,28 +183,6 @@ export function MemoryKnowledgePanel({
return () => window.clearInterval(timerId);
}, [reindexActivity]);
- useEffect(() => stopReindexPolling, []);
-
- useEffect(() => {
- if (!reindexActivity || reindexActivity.finishedAtMs === null) {
- return;
- }
-
- if (reindexActivity.phase === "settled") {
- setReindexFeedback(t("memory.knowledge.reindexLive.settledFeedback"));
- return;
- }
-
- if (reindexActivity.phase === "warning") {
- setReindexFeedback(reindexActivity.syncIssue ? null : t("memory.knowledge.reindexLive.warningFeedback"));
- return;
- }
-
- if (reindexActivity.phase === "failed") {
- setReindexFeedback(null);
- }
- }, [reindexActivity, t]);
-
const setFieldError = (field: keyof FieldErrorState, message: string | null) => {
setFieldErrors((current) => ({ ...current, [field]: message }));
};
@@ -264,212 +191,6 @@ export function MemoryKnowledgePanel({
setFieldErrors((current) => ({ ...current, [field]: null }));
};
- const applyRefreshedKnowledge = (
- result: MemoryKnowledgeRefreshResult,
- options: {
- token: number;
- afterCommand: boolean;
- addIssueEntry?: boolean;
- },
- ) => {
- if (activeReindexTokenRef.current !== options.token) {
- return;
- }
-
- const nextStatusSummary = buildMemoryConfigStatusSummary({
- selectedAgentId,
- isLocalGatewaySession,
- memoryResult: result.memoryResult,
- memoryStatus: result.memoryStatus,
- runtimeStatus: result.runtimeStatus,
- });
- const nextSnapshot = captureMemoryKnowledgeReindexSnapshot({
- statusSummary: nextStatusSummary,
- runtimeStatus: result.runtimeStatus,
- });
-
- let shouldStopPolling = false;
-
- setReindexActivity((current) => {
- if (!current) {
- return current;
- }
-
- const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
- const observedProgress = hasMemoryKnowledgeReindexProgress(current.latest, nextSnapshot);
- const nextEntries = [...current.entries];
- let nextPhase: ReindexActivityState["phase"] = options.afterCommand ? "syncing" : "running";
- let finishedAtMs = current.finishedAtMs;
-
- if (observedProgress && !current.progressObserved) {
- nextEntries.push(
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.progress"),
- describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot),
- ),
- );
- }
-
- if (options.addIssueEntry && current.syncIssue) {
- nextEntries.push(
- createReindexEntry(
- "warn",
- t("memory.knowledge.reindexLive.event.refreshFailed"),
- current.syncIssue,
- ),
- );
- }
-
- if (options.afterCommand && isMemoryKnowledgeReindexSettled(nextSnapshot)) {
- nextPhase = "settled";
- finishedAtMs = Date.now();
- nextEntries.push(
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.settled"),
- describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot),
- ),
- );
- shouldStopPolling = true;
- } else if (options.afterCommand && afterCommandPolls >= 6) {
- nextPhase = "warning";
- finishedAtMs = Date.now();
- nextEntries.push(
- createReindexEntry(
- "warn",
- t("memory.knowledge.reindexLive.event.warning"),
- describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot) || t("memory.knowledge.reindexLive.noDelta"),
- ),
- );
- shouldStopPolling = true;
- }
-
- return {
- ...current,
- phase: nextPhase,
- finishedAtMs,
- polls: current.polls + 1,
- afterCommandPolls,
- lastPolledAtMs: Date.now(),
- latest: nextSnapshot,
- syncIssue: null,
- progressObserved: current.progressObserved || observedProgress,
- entries: nextEntries,
- };
- });
-
- if (shouldStopPolling) {
- stopReindexPolling();
- setSavingAction(null);
- }
- };
-
- const refreshReindexProgress = async (
- token: number,
- options: {
- afterCommand: boolean;
- addIssueEntry?: boolean;
- },
- ) => {
- if (pollInFlightRef.current || activeReindexTokenRef.current !== token) {
- return;
- }
-
- pollInFlightRef.current = true;
- try {
- const result = await onRefreshKnowledge();
- if (result) {
- applyRefreshedKnowledge(result, { ...options, token });
- } else {
- let shouldStopPolling = false;
- setReindexActivity((current) => {
- if (!current) {
- return current;
- }
- const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
- const finishedAtMs =
- options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs;
- const nextEntries =
- options.afterCommand && afterCommandPolls >= 6
- ? [
- ...current.entries,
- createReindexEntry(
- "warn",
- t("memory.knowledge.reindexLive.event.warning"),
- null,
- ),
- ]
- : current.entries;
- shouldStopPolling = finishedAtMs !== null;
- return {
- ...current,
- phase: finishedAtMs !== null ? "warning" : current.phase,
- polls: current.polls + 1,
- afterCommandPolls,
- lastPolledAtMs: Date.now(),
- finishedAtMs,
- syncIssue: t("memory.knowledge.reindexLive.noRefreshPayload"),
- entries: nextEntries,
- };
- });
- if (shouldStopPolling) {
- stopReindexPolling();
- setSavingAction(null);
- }
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- let shouldStopPolling = false;
- setReindexActivity((current) => {
- if (!current) {
- return current;
- }
- const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
- const finishedAtMs =
- options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs;
- const nextEntries =
- options.afterCommand && afterCommandPolls >= 6
- ? [
- ...current.entries,
- createReindexEntry(
- "warn",
- t("memory.knowledge.reindexLive.event.refreshFailed"),
- null,
- ),
- ]
- : current.entries;
- shouldStopPolling = finishedAtMs !== null;
- return {
- ...current,
- phase: finishedAtMs !== null ? "warning" : current.phase,
- polls: current.polls + 1,
- afterCommandPolls,
- lastPolledAtMs: Date.now(),
- finishedAtMs,
- syncIssue: message,
- entries: nextEntries,
- };
- });
- if (shouldStopPolling) {
- stopReindexPolling();
- setSavingAction(null);
- }
- } finally {
- pollInFlightRef.current = false;
- }
- };
-
- const startReindexPolling = (token: number) => {
- stopReindexPolling();
- pollTimerRef.current = window.setInterval(() => {
- void refreshReindexProgress(token, {
- afterCommand: commandCompletedRef.current,
- addIssueEntry: commandCompletedRef.current,
- });
- }, 1500);
- };
-
const runAction = async (
kind: Exclude,
field: keyof FieldErrorState,
@@ -481,7 +202,6 @@ export function MemoryKnowledgePanel({
const result = await runner();
await onRefreshKnowledge();
setConfigFeedback(result.stdout || t("memory.knowledge.configUpdated"));
- setReindexFeedback(null);
return result;
} catch (error) {
const failure = error as MemoryKnowledgeActionFailure;
@@ -503,197 +223,6 @@ export function MemoryKnowledgePanel({
}
};
- const handleRunReindex = async () => {
- if (!statusSummary.localWritable || !selectedAgentId) {
- return;
- }
-
- const token = Date.now();
- activeReindexTokenRef.current = token;
- commandCompletedRef.current = false;
- setReindexDetailsExpanded(true);
- setSavingAction("reindex");
- clearFieldError("reindex");
- setConfigFeedback(null);
- setReindexFeedback(null);
- setReindexActivity({
- phase: "starting",
- startedAtMs: Date.now(),
- finishedAtMs: null,
- polls: 0,
- afterCommandPolls: 0,
- lastPolledAtMs: null,
- before: currentReindexSnapshot,
- latest: currentReindexSnapshot,
- commandStdout: null,
- syncIssue: null,
- progressObserved: false,
- entries: [
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.submitted"),
- t("memory.knowledge.reindexLive.event.submittedDetail"),
- ),
- ],
- });
- startReindexPolling(token);
- void refreshReindexProgress(token, { afterCommand: false });
- try {
- const result = await runExternalKnowledgeReindex(
- selectedAgentId,
- statusSummary.reindexStrategy,
- t,
- selectedSessionId ?? undefined,
- );
- if (activeReindexTokenRef.current !== token) {
- return;
- }
- commandCompletedRef.current = true;
- setReindexActivity((current) =>
- current
- ? {
- ...current,
- phase: "syncing",
- commandStdout: result.stdout || t("memory.knowledge.reindexDone"),
- entries: [
- ...current.entries,
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.commandDone"),
- result.stdout || t("memory.knowledge.reindexDone"),
- ),
- ],
- }
- : current,
- );
- await refreshReindexProgress(token, { afterCommand: true });
- setReindexFeedback(t("memory.knowledge.reindexLive.syncingFeedback"));
- toast.success(t("memory.knowledge.reindexLive.commandAccepted"));
- } catch (error) {
- const failure = error as MemoryKnowledgeActionFailure;
- stopReindexPolling();
- setReindexActivity((current) =>
- current
- ? {
- ...current,
- phase: "failed",
- finishedAtMs: Date.now(),
- syncIssue: failure.message,
- entries: [
- ...current.entries,
- createReindexEntry(
- "error",
- t("memory.knowledge.reindexLive.event.failed"),
- null,
- ),
- ],
- }
- : current,
- );
- toast.error(failure.message);
- } finally {
- if (commandCompletedRef.current || activeReindexTokenRef.current !== token) {
- return;
- }
- setSavingAction(null);
- }
- };
-
- const runPostConfigReindex = async () => {
- if (!autoReindexEnabled || !statusSummary.localWritable || !selectedAgentId) {
- return;
- }
-
- const token = Date.now();
- activeReindexTokenRef.current = token;
- commandCompletedRef.current = false;
- setReindexDetailsExpanded(true);
- setSavingAction("reindex");
- clearFieldError("reindex");
- setReindexFeedback(t("memory.knowledge.reindexAutoRunning"));
- setReindexActivity({
- phase: "starting",
- startedAtMs: Date.now(),
- finishedAtMs: null,
- polls: 0,
- afterCommandPolls: 0,
- lastPolledAtMs: null,
- before: currentReindexSnapshot,
- latest: currentReindexSnapshot,
- commandStdout: null,
- syncIssue: null,
- progressObserved: false,
- entries: [
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.autoSubmitted"),
- t("memory.knowledge.reindexLive.event.submittedDetail"),
- ),
- ],
- });
- startReindexPolling(token);
- void refreshReindexProgress(token, { afterCommand: false });
- try {
- const result = await runExternalKnowledgeReindex(
- selectedAgentId,
- statusSummary.reindexStrategy,
- t,
- selectedSessionId ?? undefined,
- );
- if (activeReindexTokenRef.current !== token) {
- return;
- }
- commandCompletedRef.current = true;
- setReindexActivity((current) =>
- current
- ? {
- ...current,
- phase: "syncing",
- commandStdout: result.stdout || t("memory.knowledge.reindexDone"),
- entries: [
- ...current.entries,
- createReindexEntry(
- "info",
- t("memory.knowledge.reindexLive.event.commandDone"),
- result.stdout || t("memory.knowledge.reindexDone"),
- ),
- ],
- }
- : current,
- );
- await refreshReindexProgress(token, { afterCommand: true });
- setReindexFeedback(t("memory.knowledge.reindexLive.syncingFeedback"));
- } catch (error) {
- const failure = error as MemoryKnowledgeActionFailure;
- stopReindexPolling();
- setReindexActivity((current) =>
- current
- ? {
- ...current,
- phase: "failed",
- finishedAtMs: Date.now(),
- syncIssue: failure.message,
- entries: [
- ...current.entries,
- createReindexEntry(
- "error",
- t("memory.knowledge.reindexLive.event.failed"),
- null,
- ),
- ],
- }
- : current,
- );
- toast.error(failure.message);
- setSavingAction(null);
- } finally {
- if (commandCompletedRef.current || activeReindexTokenRef.current !== token) {
- return;
- }
- setSavingAction(null);
- }
- };
-
const handleAddExtraPath = async () => {
const normalized = newExtraPath.trim();
if (!normalized) {
@@ -718,7 +247,7 @@ export function MemoryKnowledgePanel({
if (result) {
startTransition(() => setNewExtraPath(""));
toast.success(t("memory.knowledge.pathAdded"));
- await runPostConfigReindex();
+ await onRunAutoReindex();
}
};
@@ -735,7 +264,7 @@ export function MemoryKnowledgePanel({
);
if (result) {
toast.success(t("memory.knowledge.pathRemoved"));
- await runPostConfigReindex();
+ await onRunAutoReindex();
}
};
@@ -771,7 +300,7 @@ export function MemoryKnowledgePanel({
);
if (result) {
toast.success(t(enabled ? "memory.knowledge.sessionToggleOn" : "memory.knowledge.sessionToggleOff"));
- await runPostConfigReindex();
+ await onRunAutoReindex();
}
};
@@ -801,7 +330,7 @@ export function MemoryKnowledgePanel({
);
if (result) {
toast.success(t("memory.knowledge.sourcesUpdated"));
- await runPostConfigReindex();
+ await onRunAutoReindex();
}
};
@@ -1068,7 +597,7 @@ export function MemoryKnowledgePanel({
{showReindexRecoveryActions && statusSummary.localWritable ? (
-
void handleRunReindex()} disabled={controlsDisabled} variant="primary">
+ void onRunReindex()} disabled={controlsDisabled} variant="primary">
{t("memory.knowledge.reindexLive.retry")}
@@ -1081,7 +610,7 @@ export function MemoryKnowledgePanel({
) : null}
setReindexDetailsExpanded((current) => !current)}
+ onClick={onToggleReindexDetails}
disabled={false}
>
{reindexDetailsExpanded ? (
@@ -1112,7 +641,7 @@ export function MemoryKnowledgePanel({
{statusSummary.localWritable ? (
void handleRunReindex()}
+ onClick={() => void onRunReindex()}
disabled={controlsDisabled}
variant="primary"
>
@@ -1242,7 +771,6 @@ export function MemoryKnowledgePanel({
) : null}
- {fieldErrors.reindex && !reindexActivity?.syncIssue ? : null}
diff --git a/src/app/components/views/MemoryView.reindexPersistence.test.tsx b/src/app/components/views/MemoryView.reindexPersistence.test.tsx
new file mode 100644
index 0000000..c0c6845
--- /dev/null
+++ b/src/app/components/views/MemoryView.reindexPersistence.test.tsx
@@ -0,0 +1,217 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+
+vi.mock("motion/react", () => ({
+ AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+vi.mock("../../contexts/I18nContext", () => ({
+ useI18n: () => ({
+ t: (key: string, ...args: (string | number)[]) =>
+ args.length ? `${key}:${args.join(",")}` : key,
+ }),
+}));
+
+const openClawState = {
+ nodes: [
+ {
+ id: "gateway:http://127.0.0.1:3100",
+ name: "OpenClaw Local",
+ status: "online" as const,
+ sessionId: "session-local",
+ origin: "http://127.0.0.1:3100",
+ grantedScopes: ["operator.admin"],
+ isActive: true,
+ },
+ ],
+ agents: [
+ {
+ id: "agent-main",
+ name: "Main Agent",
+ nodeId: "gateway:http://127.0.0.1:3100",
+ status: "active" as const,
+ },
+ ],
+ grantedScopes: ["operator.admin"],
+ isConnected: true,
+ connectedOrigin: "http://127.0.0.1:3100",
+ setActiveSession: vi.fn(),
+};
+
+vi.mock("../../contexts/OpenClawContext", () => ({
+ useOpenClaw: () => openClawState,
+ gatewayAgentMemoryGet: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ workspace: "workspace",
+ documents: [
+ {
+ name: "MEMORY.md",
+ path: "workspace/MEMORY.md",
+ missing: false,
+ content: "# Memory",
+ },
+ ],
+ sharedAgents: [],
+ diagnostics: {
+ memorySearchEnabled: true,
+ backend: "builtin",
+ provider: "openai",
+ embeddingModel: "text-embedding-3-large",
+ builtinStorePath: "~/.openclaw/memory/main.sqlite",
+ sources: ["memory"],
+ extraPaths: ["D:/shared/notes"],
+ sessionMemoryEnabled: false,
+ qmdActive: false,
+ qmdHome: null,
+ qmdPaths: [],
+ qmdSessionsEnabled: false,
+ },
+ }),
+ gatewayAgentMemorySet: vi.fn(),
+ gatewayAgentMemoryIndex: vi.fn(),
+ gatewayAgentFileRead: vi.fn(),
+ gatewayAgentMemorySearch: vi.fn(),
+ gatewayAgentMemoryRuntimeStatus: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ embeddingOk: true,
+ vectorOk: true,
+ status: {
+ backend: "builtin",
+ files: 0,
+ totalFiles: 0,
+ chunks: 0,
+ dirty: true,
+ workspaceDir: null,
+ dbPath: null,
+ provider: "openai",
+ model: "text-embedding-3-large",
+ requestedProvider: "openai",
+ sources: ["memory"],
+ extraPaths: ["D:/shared/notes"],
+ sourceCounts: [],
+ },
+ rawPayload: "{}",
+ }),
+ gatewayAgentMemoryStatus: vi.fn().mockResolvedValue(null),
+ gatewayAgentMemoryTimelineAccessResolve: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ workspace: "workspace",
+ mode: "local_workspace",
+ reason: "workspace_local_and_readable",
+ }),
+ gatewayAgentMemoryTimelineEntryRead: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ workspace: "workspace",
+ file: {
+ name: "memory/2026-04-20.md",
+ path: "workspace/memory/2026-04-20.md",
+ missing: false,
+ content: "",
+ },
+ }),
+ gatewayAgentMemoryTimelineGet: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ workspace: "workspace",
+ source: "local_workspace",
+ entries: [],
+ diagnostics: {
+ gatewayVisibleFilesCount: 0,
+ gatewayVisibleRootDocsCount: 0,
+ gatewayVisibleDailyCount: 0,
+ gatewayOnlyReturnedRootDocs: false,
+ localScanDirectory: null,
+ localScanFilesCount: 0,
+ localScanSkippedCount: 0,
+ },
+ probe: null,
+ }),
+ gatewayAgentMemoryTimelineLocalScan: vi.fn().mockResolvedValue({
+ agentId: "agent-main",
+ workspace: "workspace",
+ source: "local_workspace",
+ entries: [],
+ diagnostics: {
+ gatewayVisibleFilesCount: 0,
+ gatewayVisibleRootDocsCount: 0,
+ gatewayVisibleDailyCount: 0,
+ gatewayOnlyReturnedRootDocs: false,
+ localScanDirectory: null,
+ localScanFilesCount: 0,
+ localScanSkippedCount: 0,
+ },
+ probe: null,
+ }),
+ gatewayAgentMemoryTimelineRemoteProbeDates: vi.fn(),
+ gatewayAgentMemoryTimelineRemoteProbe: vi.fn(),
+}));
+
+vi.mock("./memoryKnowledgeActions", () => ({
+ runExternalKnowledgeReindex: vi.fn(
+ () => new Promise(() => undefined),
+ ),
+}));
+
+vi.mock("./MemoryDiagnosticsDrawer", () => ({
+ MemoryDiagnosticsDrawer: () => null,
+}));
+vi.mock("./MemorySearchPanel", () => ({
+ MemorySearchPanel: () =>
search-panel
,
+}));
+vi.mock("./MemoryFootprintsPanel", () => ({
+ MemoryFootprintsPanel: () =>
footprints-panel
,
+}));
+vi.mock("./MemoryResourcesPanel", () => ({
+ MemoryResourcesPanel: () =>
resources-panel
,
+}));
+vi.mock("./MemoryDocumentsDesktop", () => ({
+ MemoryDocumentsDesktop: () =>
documents-desktop
,
+}));
+vi.mock("./MemoryDocumentsMobile", () => ({
+ MemoryDocumentsMobile: () =>
documents-mobile
,
+}));
+vi.mock("./MemoryKnowledgePanel", () => ({
+ MemoryKnowledgePanel: ({
+ reindexActivity,
+ onRunReindex,
+ }: {
+ reindexActivity: { phase: string } | null;
+ onRunReindex: () => Promise
;
+ }) => (
+
+
+
{`task-phase:${reindexActivity?.phase ?? "none"}`}
+
+ ),
+}));
+
+import { MemoryView } from "./MemoryView";
+
+describe("MemoryView reindex persistence", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("keeps lifted reindex state after switching away from knowledge and back", async () => {
+ render();
+ const user = userEvent.setup();
+
+ await user.click(await screen.findByText("memory.tab.knowledge"));
+ await user.click(screen.getByText("start-reindex"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/task-phase:(starting|running|syncing)/)).toBeTruthy();
+ });
+
+ await user.click(screen.getByText("memory.tab.documents"));
+ expect(screen.getByText("documents-desktop")).toBeTruthy();
+
+ await user.click(screen.getByText("memory.tab.knowledge"));
+
+ await waitFor(() => {
+ expect(screen.getByText(/task-phase:(starting|running|syncing)/)).toBeTruthy();
+ });
+ });
+});
diff --git a/src/app/components/views/MemoryView.tsx b/src/app/components/views/MemoryView.tsx
index 162a631..036e94e 100644
--- a/src/app/components/views/MemoryView.tsx
+++ b/src/app/components/views/MemoryView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Calendar, Network, Cpu, BrainCircuit, ChevronDown, BookOpen, FileText, Info, LibraryBig } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { toast } from "sonner";
@@ -59,7 +59,19 @@ import {
resolveSelectedMemoryAgentIdForNode,
resolveSelectedMemoryNodeId,
} from "./memoryNodeState";
-import type { MemoryKnowledgeRefreshResult } from "./memoryKnowledgeReindexState";
+import {
+ captureMemoryKnowledgeReindexSnapshot,
+ describeMemoryKnowledgeReindexDelta,
+ hasMemoryKnowledgeReindexProgress,
+ isMemoryKnowledgeReindexSettled,
+ type MemoryKnowledgeRefreshResult,
+ type MemoryKnowledgeReindexActivityState,
+ type ReindexTimelineEntry,
+} from "./memoryKnowledgeReindexState";
+import {
+ runExternalKnowledgeReindex,
+ type MemoryKnowledgeActionFailure,
+} from "./memoryKnowledgeActions";
import {
canRunSemanticMemorySearch,
resolveSemanticMemorySearchGroup,
@@ -377,6 +389,14 @@ export function MemoryView() {
const [_timelineEntryContent, setTimelineEntryContent] = useState("");
const [_timelineEntryLoading, setTimelineEntryLoading] = useState(false);
const [_timelineEntryError, setTimelineEntryError] = useState(null);
+ const [knowledgeReindexActivity, setKnowledgeReindexActivity] =
+ useState(null);
+ const [knowledgeReindexFeedback, setKnowledgeReindexFeedback] = useState(null);
+ const [knowledgeReindexDetailsExpanded, setKnowledgeReindexDetailsExpanded] = useState(true);
+ const knowledgeReindexTokenRef = useRef(0);
+ const knowledgeReindexPollTimerRef = useRef(null);
+ const knowledgeReindexPollInFlightRef = useRef(false);
+ const knowledgeReindexCommandCompletedRef = useRef(false);
const selectedNodeEntry = useMemo(
() => memoryNodeEntries.find((entry) => entry.id === selectedNodeId) ?? null,
@@ -959,6 +979,360 @@ export function MemoryView() {
? memoryConfigStatus.providerAvailabilityReasonKey
: memoryConfigStatus.searchAvailabilityReasonKey;
const searchPrimaryReason = searchPrimaryReasonKey ? t(searchPrimaryReasonKey) : null;
+ const currentKnowledgeReindexSnapshot = useMemo(
+ () =>
+ captureMemoryKnowledgeReindexSnapshot({
+ statusSummary: memoryConfigStatus,
+ runtimeStatus: memoryRuntimeStatus,
+ }),
+ [memoryConfigStatus, memoryRuntimeStatus],
+ );
+
+ const createKnowledgeReindexEntry = (
+ tone: ReindexTimelineEntry["tone"],
+ title: string,
+ detail?: string | null,
+ ): ReindexTimelineEntry => ({
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ tone,
+ title,
+ detail,
+ atMs: Date.now(),
+ });
+
+ const stopKnowledgeReindexPolling = () => {
+ if (knowledgeReindexPollTimerRef.current !== null) {
+ window.clearInterval(knowledgeReindexPollTimerRef.current);
+ knowledgeReindexPollTimerRef.current = null;
+ }
+ knowledgeReindexPollInFlightRef.current = false;
+ };
+
+ const applyKnowledgeReindexRefresh = (
+ result: MemoryKnowledgeRefreshResult,
+ options: {
+ token: number;
+ afterCommand: boolean;
+ addIssueEntry?: boolean;
+ },
+ ) => {
+ if (knowledgeReindexTokenRef.current !== options.token) {
+ return;
+ }
+
+ const nextStatusSummary = buildMemoryConfigStatusSummary({
+ selectedAgentId,
+ isLocalGatewaySession,
+ memoryResult: result.memoryResult,
+ memoryStatus: result.memoryStatus,
+ runtimeStatus: result.runtimeStatus,
+ });
+ const nextSnapshot = captureMemoryKnowledgeReindexSnapshot({
+ statusSummary: nextStatusSummary,
+ runtimeStatus: result.runtimeStatus,
+ });
+
+ let shouldStopPolling = false;
+
+ setKnowledgeReindexActivity((current) => {
+ if (!current) {
+ return current;
+ }
+
+ const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
+ const observedProgress = hasMemoryKnowledgeReindexProgress(current.latest, nextSnapshot);
+ const nextEntries = [...current.entries];
+ let nextPhase: MemoryKnowledgeReindexActivityState["phase"] = options.afterCommand ? "syncing" : "running";
+ let finishedAtMs = current.finishedAtMs;
+
+ if (observedProgress && !current.progressObserved) {
+ nextEntries.push(
+ createKnowledgeReindexEntry(
+ "info",
+ t("memory.knowledge.reindexLive.event.progress"),
+ describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot),
+ ),
+ );
+ }
+
+ if (options.addIssueEntry && current.syncIssue) {
+ nextEntries.push(
+ createKnowledgeReindexEntry(
+ "warn",
+ t("memory.knowledge.reindexLive.event.refreshFailed"),
+ current.syncIssue,
+ ),
+ );
+ }
+
+ if (options.afterCommand && isMemoryKnowledgeReindexSettled(nextSnapshot)) {
+ nextPhase = "settled";
+ finishedAtMs = Date.now();
+ nextEntries.push(
+ createKnowledgeReindexEntry(
+ "info",
+ t("memory.knowledge.reindexLive.event.settled"),
+ describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot),
+ ),
+ );
+ shouldStopPolling = true;
+ } else if (options.afterCommand && afterCommandPolls >= 6) {
+ nextPhase = "warning";
+ finishedAtMs = Date.now();
+ nextEntries.push(
+ createKnowledgeReindexEntry(
+ "warn",
+ t("memory.knowledge.reindexLive.event.warning"),
+ describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot) || t("memory.knowledge.reindexLive.noDelta"),
+ ),
+ );
+ shouldStopPolling = true;
+ }
+
+ return {
+ ...current,
+ phase: nextPhase,
+ finishedAtMs,
+ polls: current.polls + 1,
+ afterCommandPolls,
+ lastPolledAtMs: Date.now(),
+ latest: nextSnapshot,
+ syncIssue: null,
+ progressObserved: current.progressObserved || observedProgress,
+ entries: nextEntries,
+ };
+ });
+
+ if (shouldStopPolling) {
+ stopKnowledgeReindexPolling();
+ }
+ };
+
+ const refreshKnowledgeReindexProgress = async (
+ token: number,
+ options: {
+ afterCommand: boolean;
+ addIssueEntry?: boolean;
+ },
+ ) => {
+ if (knowledgeReindexPollInFlightRef.current || knowledgeReindexTokenRef.current !== token) {
+ return;
+ }
+
+ knowledgeReindexPollInFlightRef.current = true;
+ try {
+ const result = await handleRefreshKnowledge();
+ if (result) {
+ applyKnowledgeReindexRefresh(result, { ...options, token });
+ } else {
+ let shouldStopPolling = false;
+ setKnowledgeReindexActivity((current) => {
+ if (!current) {
+ return current;
+ }
+ const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
+ const finishedAtMs =
+ options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs;
+ const nextEntries =
+ options.afterCommand && afterCommandPolls >= 6
+ ? [
+ ...current.entries,
+ createKnowledgeReindexEntry(
+ "warn",
+ t("memory.knowledge.reindexLive.event.warning"),
+ null,
+ ),
+ ]
+ : current.entries;
+ shouldStopPolling = finishedAtMs !== null;
+ return {
+ ...current,
+ phase: finishedAtMs !== null ? "warning" : current.phase,
+ polls: current.polls + 1,
+ afterCommandPolls,
+ lastPolledAtMs: Date.now(),
+ finishedAtMs,
+ syncIssue: t("memory.knowledge.reindexLive.noRefreshPayload"),
+ entries: nextEntries,
+ };
+ });
+ if (shouldStopPolling) {
+ stopKnowledgeReindexPolling();
+ }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ let shouldStopPolling = false;
+ setKnowledgeReindexActivity((current) => {
+ if (!current) {
+ return current;
+ }
+ const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0);
+ const finishedAtMs =
+ options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs;
+ const nextEntries =
+ options.afterCommand && afterCommandPolls >= 6
+ ? [
+ ...current.entries,
+ createKnowledgeReindexEntry(
+ "warn",
+ t("memory.knowledge.reindexLive.event.refreshFailed"),
+ null,
+ ),
+ ]
+ : current.entries;
+ shouldStopPolling = finishedAtMs !== null;
+ return {
+ ...current,
+ phase: finishedAtMs !== null ? "warning" : current.phase,
+ polls: current.polls + 1,
+ afterCommandPolls,
+ lastPolledAtMs: Date.now(),
+ finishedAtMs,
+ syncIssue: message,
+ entries: nextEntries,
+ };
+ });
+ if (shouldStopPolling) {
+ stopKnowledgeReindexPolling();
+ }
+ } finally {
+ knowledgeReindexPollInFlightRef.current = false;
+ }
+ };
+
+ const startKnowledgeReindexPolling = (token: number) => {
+ stopKnowledgeReindexPolling();
+ knowledgeReindexPollTimerRef.current = window.setInterval(() => {
+ void refreshKnowledgeReindexProgress(token, {
+ afterCommand: knowledgeReindexCommandCompletedRef.current,
+ addIssueEntry: knowledgeReindexCommandCompletedRef.current,
+ });
+ }, 1500);
+ };
+
+ useEffect(() => stopKnowledgeReindexPolling, []);
+
+ useEffect(() => {
+ if (!knowledgeReindexActivity || knowledgeReindexActivity.finishedAtMs === null) {
+ return;
+ }
+
+ if (knowledgeReindexActivity.phase === "settled") {
+ setKnowledgeReindexFeedback(t("memory.knowledge.reindexLive.settledFeedback"));
+ return;
+ }
+
+ if (knowledgeReindexActivity.phase === "warning") {
+ setKnowledgeReindexFeedback(
+ knowledgeReindexActivity.syncIssue
+ ? null
+ : t("memory.knowledge.reindexLive.warningFeedback"),
+ );
+ return;
+ }
+
+ if (knowledgeReindexActivity.phase === "failed") {
+ setKnowledgeReindexFeedback(null);
+ }
+ }, [knowledgeReindexActivity, t]);
+
+ const runKnowledgeReindex = async ({ auto }: { auto: boolean }) => {
+ if (!memoryConfigStatus.localWritable || !selectedAgentId) {
+ return;
+ }
+
+ const token = Date.now();
+ knowledgeReindexTokenRef.current = token;
+ knowledgeReindexCommandCompletedRef.current = false;
+ setKnowledgeReindexDetailsExpanded(true);
+ setKnowledgeReindexFeedback(auto ? t("memory.knowledge.reindexAutoRunning") : null);
+ setKnowledgeReindexActivity({
+ phase: "starting",
+ startedAtMs: Date.now(),
+ finishedAtMs: null,
+ polls: 0,
+ afterCommandPolls: 0,
+ lastPolledAtMs: null,
+ before: currentKnowledgeReindexSnapshot,
+ latest: currentKnowledgeReindexSnapshot,
+ commandStdout: null,
+ syncIssue: null,
+ progressObserved: false,
+ entries: [
+ createKnowledgeReindexEntry(
+ "info",
+ auto
+ ? t("memory.knowledge.reindexLive.event.autoSubmitted")
+ : t("memory.knowledge.reindexLive.event.submitted"),
+ t("memory.knowledge.reindexLive.event.submittedDetail"),
+ ),
+ ],
+ });
+ startKnowledgeReindexPolling(token);
+ void refreshKnowledgeReindexProgress(token, { afterCommand: false });
+
+ try {
+ const result = await runExternalKnowledgeReindex(
+ selectedAgentId,
+ memoryConfigStatus.reindexStrategy,
+ t,
+ selectedSessionId ?? undefined,
+ );
+ if (knowledgeReindexTokenRef.current !== token) {
+ return;
+ }
+
+ knowledgeReindexCommandCompletedRef.current = true;
+ setKnowledgeReindexActivity((current) =>
+ current
+ ? {
+ ...current,
+ phase: "syncing",
+ commandStdout: result.stdout || t("memory.knowledge.reindexDone"),
+ entries: [
+ ...current.entries,
+ createKnowledgeReindexEntry(
+ "info",
+ t("memory.knowledge.reindexLive.event.commandDone"),
+ result.stdout || t("memory.knowledge.reindexDone"),
+ ),
+ ],
+ }
+ : current,
+ );
+ await refreshKnowledgeReindexProgress(token, { afterCommand: true });
+ setKnowledgeReindexFeedback(t("memory.knowledge.reindexLive.syncingFeedback"));
+ if (!auto) {
+ toast.success(t("memory.knowledge.reindexLive.commandAccepted"));
+ }
+ } catch (error) {
+ const failure = error as MemoryKnowledgeActionFailure;
+ stopKnowledgeReindexPolling();
+ setKnowledgeReindexActivity((current) =>
+ current
+ ? {
+ ...current,
+ phase: "failed",
+ finishedAtMs: Date.now(),
+ syncIssue: failure.message,
+ entries: [
+ ...current.entries,
+ createKnowledgeReindexEntry(
+ "error",
+ t("memory.knowledge.reindexLive.event.failed"),
+ null,
+ ),
+ ],
+ }
+ : current,
+ );
+ toast.error(failure.message);
+ }
+ };
+
+ const isKnowledgeReindexBusy =
+ knowledgeReindexActivity !== null && knowledgeReindexActivity.finishedAtMs === null;
const getAgentBadge = (agentId: string) => {
const agent = agents.find(a => a.id === agentId);
@@ -1549,6 +1923,13 @@ export function MemoryView() {
openHint={mindMapOpenHint}
onRefreshKnowledge={handleRefreshKnowledge}
onOpenDiagnostics={() => setDiagnosticsDrawer({ open: true, source: "knowledge" })}
+ reindexActivity={knowledgeReindexActivity}
+ reindexDetailsExpanded={knowledgeReindexDetailsExpanded}
+ reindexFeedback={knowledgeReindexFeedback}
+ isReindexBusy={isKnowledgeReindexBusy}
+ onToggleReindexDetails={() => setKnowledgeReindexDetailsExpanded((current) => !current)}
+ onRunReindex={() => runKnowledgeReindex({ auto: false })}
+ onRunAutoReindex={() => runKnowledgeReindex({ auto: true })}
/>
),
diff --git a/src/app/components/views/memoryKnowledgeReindexState.ts b/src/app/components/views/memoryKnowledgeReindexState.ts
index 1f672d3..2f8cd79 100644
--- a/src/app/components/views/memoryKnowledgeReindexState.ts
+++ b/src/app/components/views/memoryKnowledgeReindexState.ts
@@ -28,6 +28,29 @@ export type MemoryKnowledgeReindexPhase =
| "warning"
| "failed";
+export type ReindexTimelineEntry = {
+ id: string;
+ tone: "info" | "warn" | "error";
+ title: string;
+ detail?: string | null;
+ atMs: number;
+};
+
+export type MemoryKnowledgeReindexActivityState = {
+ phase: MemoryKnowledgeReindexPhase;
+ startedAtMs: number;
+ finishedAtMs: number | null;
+ polls: number;
+ afterCommandPolls: number;
+ lastPolledAtMs: number | null;
+ before: MemoryKnowledgeReindexSnapshot;
+ latest: MemoryKnowledgeReindexSnapshot;
+ commandStdout: string | null;
+ syncIssue: string | null;
+ progressObserved: boolean;
+ entries: ReindexTimelineEntry[];
+};
+
export function captureMemoryKnowledgeReindexSnapshot({
statusSummary,
runtimeStatus,
From 5e226b9c0d8fd4d9853a6e74e9581edc78485a41 Mon Sep 17 00:00:00 2001
From: milome
Date: Mon, 20 Apr 2026 10:56:17 +0800
Subject: [PATCH 17/17] =?UTF-8?q?chore(release):=20=E5=8D=87=E7=BA=A7?=
=?UTF-8?q?=E7=89=88=E6=9C=AC=E5=88=B00.1.4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 2 +-
src-tauri/Cargo.toml | 2 +-
src-tauri/tauri.conf.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 56f6225..26bad66 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "claw-scope",
"private": true,
- "version": "0.1.3",
+ "version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 0aa3c7b..0753b88 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "claw-scope"
-version = "0.1.3"
+version = "0.1.4"
description = "ClawScope - 记忆可见,进化可期"
authors = ["milome"]
license = "MIT"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 6621937..c403f77 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ClawScope",
- "version": "0.1.3",
+ "version": "0.1.4",
"identifier": "com.claw.scope",
"build": {
"beforeDevCommand": "npm run dev",