You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/22 06:31:42 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)

This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 1ef5d8607 fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)
1ef5d8607 is described below

commit 1ef5d860799ac3fabfcdea942cc4dcc77ded7be6
Author: lizhimins <70...@qq.com>
AuthorDate: Tue Sep 22 14:31:35 2026 +0800

    fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)
    
    MCP tool layer (server):
    - rmq.proxy.list / rmq.proxy.config no longer require PROXY_DISCOVERY on
      the bound instance: both aggregate every manageable instance through
      the heartbeat-syncer group, so gating them on the bound instance type
      made them permanently UNSUPPORTED in direct-attached deployments even
      with proxies running; they join audit.list/alert.rule.list as ungated
    - rmq.instance.metrics accepts the sole shared data source bound to the
      instance, still preferring a dedicated one
    - CORS allowedMethods gains PATCH: browsers attach an Origin header to
      every non-GET request, so the missing verb answered 403 "Invalid CORS
      request" on conversation rename/archive in every real browser
    
    AI conversation surface (web):
    - memoise transcript bubbles and blocks: a streaming tick re-rendered the
      page at display rate and re-ran ReactMarkdown over every persisted
      answer plus every finished tool card each frame
    - follow the stream with instant scrolls instead of stacking a fresh
      smooth scroll on every animation frame
    - show the operator's question immediately via an optimistic user
      bubble; the stream carries no user frame and the transcript refetches
      only when the run finishes, so the question was invisible for minutes
    - history modal gains a per-row archive/unarchive action
    - context meter pairs a mini bar with the percent number, hidden below
      1%; composer header wraps on narrow viewports; send button slims to
      32px
    
    Cluster/clients console (web):
    - broker config modal compacted into a grouped two-column grid; the
      permission bitfield becomes a named Select; the hard-coded queue hint
      becomes a translated tooltip
    - diagnostics tables clip long values with ellipsis + title instead of
      spilling over neighbouring columns
    
    rmqctl:
    - native Windows build script; plain HTTP allowed for private-network
      Studio endpoints
    
    Tests:
    - stabilise three flake families under parallel load: antd spin-blur
      lagging the loading prop one commit (pointerEventsCheck: 0 on
      table-driving suites), CSV import waits racing the instance load
      (wait for the instance-scoped fetch), static message/notification
      holders leaking across tests (global afterEach destroy)
    - pin the diagnostics race test to fake intervals; new coverage for the
      metrics source selection, optimistic bubble, scroll behaviour, CORS
      PATCH and the archive flow
---
 rmqctl/internal/studio/auth.go                     |  16 +++
 rmqctl/internal/studio/client.go                   |   4 +-
 rmqctl/internal/studio/client_test.go              |  78 +++++++++++++
 rmqctl/scripts/build-windows.ps1                   |  94 +++++++++++++++
 .../studio/cluster/metrics/MetricsService.java     |  26 +++--
 .../rocketmq/studio/common/config/CorsConfig.java  |   6 +-
 .../ops/ai/tool/filter/ToolCapabilityFilter.java   |   9 +-
 .../main/resources/tool-catalog/tools/proxy.yaml   |   6 +-
 .../studio/cluster/metrics/MetricsServiceTest.java |  77 +++++++++++++
 .../studio/common/config/CorsConfigTest.java       |  17 +++
 web/src/i18n/translations.ts                       |  16 +++
 web/src/index.css                                  |   6 +-
 web/src/pages/ai/components/AssistantBubble.tsx    |   9 +-
 web/src/pages/ai/components/BubbleList.tsx         |  21 +++-
 web/src/pages/ai/components/ChatThread.tsx         |  20 +++-
 web/src/pages/ai/components/Composer.tsx           |  35 ++++--
 .../pages/ai/components/ConversationListModal.tsx  |  81 ++++++++++---
 web/src/pages/ai/components/SendStopButton.tsx     |   8 +-
 web/src/pages/ai/components/UserBubble.tsx         |   3 +-
 .../ai/components/__tests__/ChatThread.test.tsx    |  73 +++++++++++-
 .../__tests__/ConversationListModal.test.tsx       |  37 ++++++
 web/src/pages/ai/components/blocks/ErrorBlock.tsx  |   3 +-
 web/src/pages/ai/components/blocks/NoticeBlock.tsx |   3 +-
 web/src/pages/ai/components/blocks/TextBlock.tsx   |   9 +-
 .../pages/ai/components/blocks/ThinkingBlock.tsx   |   4 +-
 web/src/pages/ai/components/blocks/ToolBlock.tsx   |   8 +-
 web/src/pages/ai/hooks/useAgentRun.ts              |  30 ++++-
 web/src/pages/ai/index.tsx                         |  25 ++--
 .../pages/cluster/__tests__/ClientsPage.test.tsx   |  32 +++---
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  24 ++--
 web/src/pages/cluster/clients.tsx                  |  54 +++++++--
 web/src/pages/cluster/index.tsx                    | 127 ++++++++++++++-------
 .../pages/home/__tests__/DashboardPage.test.tsx    |  10 +-
 web/src/pages/instance/__tests__/AclPage.test.tsx  |  40 +++----
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  75 ++++++------
 .../__tests__/ConsumerPageDiagnosticsRace.test.tsx |   8 +-
 web/src/pages/instance/__tests__/DLQPage.test.tsx  |  34 +++---
 .../pages/instance/__tests__/InstancePage.test.tsx |  46 ++++----
 .../pages/instance/__tests__/MessagePage.test.tsx  |  24 ++--
 .../__tests__/MessagePageAsyncState.test.tsx       |  36 +++---
 .../pages/instance/__tests__/TopicPage.test.tsx    |  70 +++++++-----
 .../__tests__/NotificationDeliveriesPage.test.tsx  |   6 +-
 web/src/test/setup.ts                              |  10 ++
 43 files changed, 1001 insertions(+), 319 deletions(-)

diff --git a/rmqctl/internal/studio/auth.go b/rmqctl/internal/studio/auth.go
index 85bd50ea2..fa234324f 100644
--- a/rmqctl/internal/studio/auth.go
+++ b/rmqctl/internal/studio/auth.go
@@ -118,3 +118,19 @@ func isLoopbackHost(host string) bool {
 	ip := net.ParseIP(host)
 	return ip != nil && ip.IsLoopback()
 }
+
+// isPrivateHost reports whether host is a loopback, RFC1918 private-network,
+// or link-local address. Plain HTTP is permitted for these non-routable,
+// trusted networks (local development and same-VPC/intranet deployments);
+// public endpoints must still use HTTPS so that HMAC credentials are never
+// transmitted in the clear over the Internet.
+func isPrivateHost(host string) bool {
+	if isLoopbackHost(host) {
+		return true
+	}
+	ip := net.ParseIP(host)
+	if ip == nil {
+		return false
+	}
+	return ip.IsPrivate() || ip.IsLinkLocalUnicast()
+}
diff --git a/rmqctl/internal/studio/client.go b/rmqctl/internal/studio/client.go
index 04ec2c56d..b459fc78d 100644
--- a/rmqctl/internal/studio/client.go
+++ b/rmqctl/internal/studio/client.go
@@ -58,8 +58,8 @@ func (t Target) validate() error {
 	if err != nil || server.Hostname() == "" {
 		return fmt.Errorf("invalid Studio Server URL %q", t.Server)
 	}
-	if server.Scheme != "https" && !(server.Scheme == "http" && isLoopbackHost(server.Hostname())) {
-		return fmt.Errorf("studio server must use HTTPS (HTTP is allowed only for loopback development)")
+	if server.Scheme != "https" && !(server.Scheme == "http" && isPrivateHost(server.Hostname())) {
+		return fmt.Errorf("studio server must use HTTPS (HTTP is allowed only for loopback or private-network addresses)")
 	}
 	if server.User != nil || server.RawQuery != "" || server.Fragment != "" {
 		return fmt.Errorf("studio server URL must not contain user info, a query, or a fragment")
diff --git a/rmqctl/internal/studio/client_test.go b/rmqctl/internal/studio/client_test.go
new file mode 100644
index 000000000..7bc6fd464
--- /dev/null
+++ b/rmqctl/internal/studio/client_test.go
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package studio
+
+import "testing"
+
+// TestValidateServerSchemeAdaptiveHTTP verifies the adaptive scheme policy:
+// HTTPS is always accepted, plain HTTP is accepted for loopback / RFC1918
+// private / link-local hosts, and rejected for public hosts.
+func TestValidateServerSchemeAdaptiveHTTP(t *testing.T) {
+	tests := []struct {
+		name    string
+		server  string
+		wantErr bool
+	}{
+		{"https public host", "https://studio.example.com", false},
+		{"http ipv4 loopback", "http://127.0.0.1:6789", false},
+		{"http localhost", "http://localhost:6789", false},
+		{"http ipv6 loopback", "http://[::1]:6789", false},
+		{"http private 10/8", "http://10.0.3.104:6789", false},
+		{"http private 172.16/12", "http://172.16.5.10:6789", false},
+		{"http private 192.168/16", "http://192.168.1.20:6789", false},
+		{"http link-local 169.254/16", "http://169.254.1.5:6789", false},
+		{"http public ipv4 rejected", "http://47.98.43.243:6789", true},
+		{"http public domain rejected", "http://studio.example.com", true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			target := Target{
+				Server:     tt.server,
+				InstanceID: "test-instance",
+				Credential: Credential{AccessKey: "ak", SecretKey: "sk"},
+			}
+			err := target.validate()
+			if tt.wantErr && err == nil {
+				t.Fatalf("validate(%q) = nil, want HTTPS-required error", tt.server)
+			}
+			if !tt.wantErr && err != nil {
+				t.Fatalf("validate(%q) = %v, want nil", tt.server, err)
+			}
+		})
+	}
+}
+
+func TestIsPrivateHost(t *testing.T) {
+	private := []string{
+		"localhost", "127.0.0.1", "::1",
+		"10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.0.1", "169.254.1.1",
+	}
+	for _, host := range private {
+		if !isPrivateHost(host) {
+			t.Errorf("isPrivateHost(%q) = false, want true", host)
+		}
+	}
+	public := []string{
+		"studio.example.com", "47.98.43.243", "8.8.8.8",
+		"172.15.0.1", "172.32.0.1", "11.0.0.1",
+	}
+	for _, host := range public {
+		if isPrivateHost(host) {
+			t.Errorf("isPrivateHost(%q) = true, want false", host)
+		}
+	}
+}
diff --git a/rmqctl/scripts/build-windows.ps1 b/rmqctl/scripts/build-windows.ps1
new file mode 100644
index 000000000..275cd0f95
--- /dev/null
+++ b/rmqctl/scripts/build-windows.ps1
@@ -0,0 +1,94 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+<#
+.SYNOPSIS
+    Native Windows build for rmqctl (equivalent to `make build` on Unix).
+
+.DESCRIPTION
+    Compiles rmqctl.exe with the local Go toolchain on Windows, injecting the
+    same version metadata as the Makefile LDFLAGS. This is the native-build
+    counterpart to the cross-compilation done by `make build-all`.
+
+    Go must be on PATH (or installed at C:\go). If the local Go is older than
+    the version required by go.mod, set GOTOOLCHAIN=auto so Go fetches the
+    right toolchain automatically.
+
+.PARAMETER Version
+    Value injected into cmd.CLIVersion (default: 1.0.0).
+
+.PARAMETER Output
+    Output binary path (default: bin\rmqctl.exe).
+
+.PARAMETER RunTests
+    Run `go test -count=1 ./...` after a successful build.
+
+.EXAMPLE
+    .\scripts\build-windows.ps1
+    .\scripts\build-windows.ps1 -Version 1.2.3 -RunTests
+#>
+param(
+    [string]$Version = "1.0.0",
+    [string]$Output = "bin\rmqctl.exe",
+    [switch]$RunTests
+)
+
+$ErrorActionPreference = "Stop"
+
+# Resolve module root: this script lives in scripts/, module root is one level up.
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$moduleRoot = Split-Path -Parent $scriptDir
+Set-Location $moduleRoot
+
+# Locate the Go toolchain.
+if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
+    if (Test-Path "C:\go\bin\go.exe") {
+        $env:Path = "C:\go\bin;" + $env:Path
+    } else {
+        throw "Go not found on PATH. Install Go (>= the version in go.mod) and retry."
+    }
+}
+
+Write-Host ">> $(go version)"
+Write-Host ">> module root: $moduleRoot"
+
+# Version metadata, mirroring the Makefile LDFLAGS.
+$gitCommit = "unknown"
+try {
+    $resolved = (git rev-parse --short HEAD 2>$null)
+    if ($resolved) { $gitCommit = $resolved }
+} catch { }
+$buildDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+$pkg = "github.com/apache/rocketmq-dashboard/rmqctl/cmd"
+$ldflags = "-s -w -X $pkg.CLIVersion=$Version -X $pkg.GitCommit=$gitCommit -X $pkg.BuildDate=$buildDate"
+
+$env:CGO_ENABLED = "0"
+
+Write-Host ">> building $Output (VERSION=$Version COMMIT=$gitCommit DATE=$buildDate)"
+go build -ldflags "$ldflags" -o "$Output" main.go
+if ($LASTEXITCODE -ne 0) { throw "go build failed (exit $LASTEXITCODE)" }
+
+$item = Get-Item "$Output"
+Write-Host ">> built $($item.FullName) ($($item.Length) bytes)"
+
+if ($RunTests) {
+    Write-Host ">> running go test -count=1 ./..."
+    go test -count=1 ./...
+    if ($LASTEXITCODE -ne 0) { throw "go test failed (exit $LASTEXITCODE)" }
+}
+
+Write-Host ">> done. verify with: .\$Output version"
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
index a50e4f771..c1c9bf26c 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
@@ -101,15 +101,27 @@ public class MetricsService {
         return source.query(resolvedQuery);
     }
 
-    /** Tool queries require a dedicated source explicitly bound to this Instance. */
+    /**
+     * Tool queries use the single data source bound to this Instance: a source bound only to
+     * this Instance wins; otherwise the sole shared source whose bindings include the Instance
+     * is accepted. Ambiguous or missing bindings stay a loud 409.
+     */
     public MetricDataVO queryInstance(String instanceId, MetricQueryDTO query) {
         if (!StringUtils.hasText(instanceId)) throw badRequest("Instance is required for metrics query");
-        List<DataSourceVO> sources = settingsService.listDataSources().stream()
-                .filter(source -> source.getInstanceIds() != null && source.getInstanceIds().size() == 1
-                        && instanceId.equals(source.getInstanceIds().get(0))).toList();
-        if (sources.size() != 1) throw new org.apache.rocketmq.studio.common.exception.BusinessException(409,
-                "Configure exactly one dedicated metrics data source bound only to Instance " + instanceId);
-        DataSourceVO source = sources.get(0);
+        List<DataSourceVO> boundSources = settingsService.listDataSources().stream()
+                .filter(source -> source.getInstanceIds() != null
+                        && source.getInstanceIds().contains(instanceId)).toList();
+        List<DataSourceVO> dedicatedSources = boundSources.stream()
+                .filter(source -> source.getInstanceIds().size() == 1).toList();
+        DataSourceVO source;
+        if (dedicatedSources.size() == 1) {
+            source = dedicatedSources.get(0);
+        } else if (dedicatedSources.isEmpty() && boundSources.size() == 1) {
+            source = boundSources.get(0);
+        } else {
+            throw new org.apache.rocketmq.studio.common.exception.BusinessException(409,
+                    "Configure exactly one metrics data source bound to Instance " + instanceId);
+        }
         if (!"none".equals(normalizeAuth(source.getAuth())))
             throw new org.apache.rocketmq.studio.common.exception.BusinessException(501,
                     "Tool queries do not support data sources requiring per-request credentials");
diff --git a/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java b/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
index cdcbd27b5..c1a8d8141 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
@@ -41,7 +41,11 @@ public class CorsConfig implements WebMvcConfigurer {
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
                 .allowedOrigins(allowedOrigins)
-                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+                // PATCH must stay in this list: browsers attach an Origin header to every
+                // non-GET request, so Spring's CORS processor vets even the same-origin PATCHes
+                // (conversation rename/archive), and a method missing here answers 403
+                // "Invalid CORS request" before the handler ever runs.
+                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 // Without exposedHeaders, browsers hide the DLQ export scan-completeness
                 // headers from cross-origin JavaScript and the export truncation warning
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
index 427278fa6..9cdf6c2fd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
@@ -39,9 +39,12 @@ import java.util.Set;
  *
  * <p>Exempt handlers never read {@code context.instanceId()} -- only this gate and
  * {@link ToolAuditFilter} do -- so binding one cannot widen the data a platform-level tool returns.
- * Tools that declare no {@code requiredCapabilities} at all (today {@code rmq.audit.list} and
- * {@code rmq.alert.rule.list}) stay ungated: their data is deployment-wide, so there is no capability
- * to check and no Instance to bind.
+ * Tools that declare no {@code requiredCapabilities} at all (today {@code rmq.audit.list},
+ * {@code rmq.alert.rule.list}, {@code rmq.proxy.list} and {@code rmq.proxy.config}) stay ungated:
+ * their data is deployment-wide, so there is no capability to check and no Instance to bind.
+ * The Proxy pair aggregates every manageable Instance through the heartbeat-syncer group, so
+ * gating them on the bound Instance's type would refuse legitimate discovery whenever that
+ * Instance is not Proxy-attached even though Proxies exist elsewhere in the deployment.
  */
 @Component
 @RequiredArgsConstructor
diff --git a/server/src/main/resources/tool-catalog/tools/proxy.yaml b/server/src/main/resources/tool-catalog/tools/proxy.yaml
index e865076db..f2019459f 100644
--- a/server/src/main/resources/tool-catalog/tools/proxy.yaml
+++ b/server/src/main/resources/tool-catalog/tools/proxy.yaml
@@ -7,8 +7,7 @@ tools:
     description: "Discover Proxy data endpoints, optionally filtered by physical cluster name. Discovery does not establish Broker cluster membership or management capability."
     riskLevel: L1
     permission: proxy:read
-    requiredCapabilities:
-      - PROXY_DISCOVERY
+    requiredCapabilities: []
     inputSchema:
       type: object
       required: []
@@ -57,8 +56,7 @@ tools:
     description: "Read the Proxy configuration and reachability snapshot for one physical cluster, with an optional addr filter (read-only; configuration reload is not exposed through MCP)."
     riskLevel: L1
     permission: proxy:read
-    requiredCapabilities:
-      - PROXY_DISCOVERY
+    requiredCapabilities: []
     inputSchema:
       type: object
       required:
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
index 86f2fb6f1..1f19b8835 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.cluster.metrics;
 
+import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
 import org.apache.rocketmq.studio.model.request.MetricsDataSourceQueryRequest;
 import org.apache.rocketmq.studio.settings.DataSourceVO;
@@ -562,4 +563,80 @@ class MetricsServiceTest {
                 .satisfies(exception -> assertThat(exception.getStatusCode()).isEqualTo(400));
         verifyNoInteractions(settingsService, metricsSourceFactory, metricsSource);
     }
+
+    @Test
+    void queryInstanceShouldPreferDedicatedSourceOverSharedSourceTest() {
+        DataSourceVO dedicated = dataSource("ds-dedicated", List.of("instance-a"));
+        DataSourceVO shared = dataSource("ds-shared", List.of("instance-a", "instance-b"));
+        when(settingsService.listDataSources()).thenReturn(List.of(shared, dedicated));
+        when(settingsService.getDataSource("ds-dedicated")).thenReturn(dedicated);
+        when(metricsSourceFactory.create(any(MetricsDataSourceConfig.class))).thenReturn(metricsSource);
+        when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(emptyMetricData());
+
+        metricsService.queryInstance("instance-a", rawQuery());
+
+        verify(settingsService).getDataSource("ds-dedicated");
+        verify(metricsSource).query(any(MetricQueryDTO.class));
+    }
+
+    @Test
+    void queryInstanceShouldAcceptSoleSharedSourceTest() {
+        DataSourceVO shared = dataSource("ds-shared", List.of("instance-a", "instance-b"));
+        when(settingsService.listDataSources()).thenReturn(List.of(shared));
+        when(settingsService.getDataSource("ds-shared")).thenReturn(shared);
+        when(metricsSourceFactory.create(any(MetricsDataSourceConfig.class))).thenReturn(metricsSource);
+        when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(emptyMetricData());
+
+        metricsService.queryInstance("instance-b", rawQuery());
+
+        verify(settingsService).getDataSource("ds-shared");
+        verify(metricsSource).query(any(MetricQueryDTO.class));
+    }
+
+    @Test
+    void queryInstanceShouldRejectInstanceWithoutAnyBoundSourceTest() {
+        DataSourceVO other = dataSource("ds-other", List.of("instance-b"));
+        when(settingsService.listDataSources()).thenReturn(List.of(other));
+
+        assertThatExceptionOfType(BusinessException.class)
+                .isThrownBy(() -> metricsService.queryInstance("instance-a", rawQuery()))
+                .satisfies(exception -> {
+                    assertThat(exception.getCode()).isEqualTo(409);
+                    assertThat(exception.getMessage())
+                            .isEqualTo("Configure exactly one metrics data source bound to Instance instance-a");
+                });
+        verifyNoInteractions(metricsSourceFactory, metricsSource);
+    }
+
+    @Test
+    void queryInstanceShouldRejectAmbiguousSharedSourcesTest() {
+        DataSourceVO first = dataSource("ds-1", List.of("instance-a", "instance-b"));
+        DataSourceVO second = dataSource("ds-2", List.of("instance-a", "instance-c"));
+        when(settingsService.listDataSources()).thenReturn(List.of(first, second));
+
+        assertThatExceptionOfType(BusinessException.class)
+                .isThrownBy(() -> metricsService.queryInstance("instance-a", rawQuery()))
+                .satisfies(exception -> assertThat(exception.getCode()).isEqualTo(409));
+        verifyNoInteractions(metricsSourceFactory, metricsSource);
+    }
+
+    private DataSourceVO dataSource(String key, List<String> instanceIds) {
+        return DataSourceVO.builder()
+                .key(key)
+                .name(key)
+                .type("prometheus")
+                .url("http://prometheus:9090")
+                .auth("none")
+                .instanceIds(instanceIds)
+                .build();
+    }
+
+    private MetricQueryDTO rawQuery() {
+        return MetricQueryDTO.builder()
+                .metric("cpu")
+                .start(1700000000L)
+                .end(1700003600L)
+                .step("1m")
+                .build();
+    }
 }
diff --git a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
index c21c45453..779a42486 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
@@ -83,6 +83,23 @@ class CorsConfigTest extends WebMvcAuthTestSupport {
                 () -> "CORS mapping must expose the DLQ export scan headers, but declared: " + exposed);
     }
 
+    @Test
+    void corsMappingShouldAllowPatchTest() {
+        // Browsers attach an Origin header to every non-GET request, so the CORS processor vets
+        // even same-origin PATCHes; without PATCH in allowedMethods the conversation rename and
+        // archive endpoints answer 403 "Invalid CORS request" in every real browser.
+        ExposingCorsRegistry registry = new ExposingCorsRegistry();
+        new CorsConfig(FRONTEND_ORIGIN).addCorsMappings(registry);
+
+        CorsConfiguration configuration = registry.getCorsConfigurations().get("/**");
+        assertNotNull(configuration);
+        List<String> methods = configuration.getAllowedMethods();
+        assertNotNull(methods, "CORS mapping must declare allowed methods");
+        assertTrue(methods.containsAll(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")),
+                () -> "CORS mapping must allow every method the REST surface uses, but declared: "
+                        + methods);
+    }
+
     @Test
     void excelExportShouldExposeScanHeadersToCrossOriginClients() throws Exception {
         when(dlqService.exportExcel(eq("instance-1"), eq("test-group"), isNull(), isNull(), isNull()))
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index bb1bceae1..b57616769 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1659,6 +1659,14 @@ const translations: Record<string, Record<Lang, string>> = {
   'ai.list.openAria': { zh: '打开会话 {title}', en: 'Open conversation {title}' },
   'ai.list.columnAction': { zh: '操作', en: 'Action' },
   'ai.list.deleteAria': { zh: '删除会话 {title}', en: 'Delete conversation {title}' },
+  'ai.list.archiveAria': { zh: '归档会话 {title}', en: 'Archive conversation {title}' },
+  'ai.list.unarchiveAria': { zh: '取消归档 {title}', en: 'Unarchive conversation {title}' },
+  'ai.list.archivedToast': { zh: '会话已归档', en: 'Conversation archived' },
+  'ai.list.unarchivedToast': {
+    zh: '会话已恢复到进行中',
+    en: 'Conversation moved back to active',
+  },
+  'ai.list.archiveFailed': { zh: '归档操作失败,请稍后重试', en: 'Archive action failed, please retry later' },
   'ai.list.deleteConfirm': { zh: '删除这条会话?', en: 'Delete this conversation?' },
   'ai.list.deleteSelected': { zh: '删除 ({count})', en: 'Delete ({count})' },
   'ai.list.deleteSelectedConfirm': {
@@ -2587,6 +2595,14 @@ const translations: Record<string, Record<Lang, string>> = {
   'cluster.writeQueues': { zh: '写队列数', en: 'Write Queues' },
   'cluster.readQueues': { zh: '读队列数', en: 'Read Queues' },
   'cluster.brokerPermission': { zh: 'Broker 权限', en: 'Broker Permission' },
+  'cluster.permRW': { zh: '读写(6)', en: 'Read/Write (6)' },
+  'cluster.permR': { zh: '只读(4)', en: 'Read-only (4)' },
+  'cluster.permW': { zh: '只写(2)', en: 'Write-only (2)' },
+  'cluster.permNone': { zh: '无权限(0)', en: 'Disabled (0)' },
+  'cluster.queueMatchHint': {
+    zh: 'Broker 默认 Topic 的读写队列数需保持一致,否则会出现不可读/不可写队列',
+    en: 'RocketMQ Broker uses one default Topic queue count; read and write values must match',
+  },
   'cluster.viewDetail': { zh: '查看详情: {addr}', en: 'View detail: {addr}' },
   'cluster.proxyDetailTitle': { zh: 'Proxy 详情 - {addr}', en: 'Proxy Detail - {addr}' },
   'cluster.restartProxyConfirm': {
diff --git a/web/src/index.css b/web/src/index.css
index 2d964ad1c..7b7434f37 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -263,9 +263,9 @@ body {
    The purple gradient is the base on purpose: the composer renders both on the AI page and on
    the home page, and the background must not depend on an `.ai-page` ancestor. */
 .ai-send-button {
-  width: 36px;
-  height: 36px;
-  flex: 0 0 36px;
+  width: 32px;
+  height: 32px;
+  flex: 0 0 32px;
   border: none;
   border-radius: 50%;
   cursor: pointer;
diff --git a/web/src/pages/ai/components/AssistantBubble.tsx b/web/src/pages/ai/components/AssistantBubble.tsx
index 3362f98b9..adb43685d 100644
--- a/web/src/pages/ai/components/AssistantBubble.tsx
+++ b/web/src/pages/ai/components/AssistantBubble.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState } from 'react';
+import { memo, useState } from 'react';
 import { Card, Flex, Tag, Tooltip, Typography, theme } from 'antd';
 import { Check, Copy } from '@phosphor-icons/react';
 import { useLang } from '../../../i18n/LangContext';
@@ -41,6 +41,11 @@ import { modelBrandLogo } from './ModelBadge';
  * `streaming` marks the bubble the run in flight is filling. It drives exactly two things: the
  * pending dots before the first block lands, and the auto-expand of the LAST thinking block (see
  * `ThinkingBlock`). `toolCatalog` is passed through to `ToolBlock` for its risk tag.
+ *
+ * Memoised because a streaming run re-renders the page at display rate, and a persisted bubble's
+ * props (the folded `Bubble` object, the catalog array, the model string) keep their identity
+ * across those renders: without the bailout every tick re-ran ReactMarkdown over EVERY persisted
+ * answer, i.e. one full transcript re-parse per frame once a conversation collected a few runs.
  */
 
 export interface AssistantBubbleProps {
@@ -302,4 +307,4 @@ const AssistantBubble = ({
   );
 };
 
-export default AssistantBubble;
+export default memo(AssistantBubble);
diff --git a/web/src/pages/ai/components/BubbleList.tsx b/web/src/pages/ai/components/BubbleList.tsx
index e1030ca7d..3bf3b4e1e 100644
--- a/web/src/pages/ai/components/BubbleList.tsx
+++ b/web/src/pages/ai/components/BubbleList.tsx
@@ -37,10 +37,17 @@ import AssistantBubble from './AssistantBubble';
 
 export interface BubbleListProps {
   bubbles: Bubble[];
-  /** Blocks of the run in flight, rendered as a trailing streaming assistant bubble. */
+  /** Blocks of the run in flight; rendered as a trailing streaming assistant bubble. */
   liveBlocks?: RenderBlock[];
   /** True while that run is streaming; drives the pending dots and the thinking auto-expand. */
   streaming?: boolean;
+  /**
+   * Optimistic text of the question the run in flight was admitted with, drawn as a user bubble
+   * ahead of the live assistant bubble: the live stream carries no user frame and the persisted
+   * transcript only gains the row at the end-of-run refetch, so without this copy the operator's
+   * own question stays invisible for the whole run.
+   */
+  pendingUserText?: string | null;
   /** Catalog from `listTools()`, so tool blocks can show a risk level. */
   toolCatalog?: readonly McpTool[];
   /** Estimated speed of the run in flight, shown live on the streaming bubble. */
@@ -65,6 +72,7 @@ const BubbleList = ({
   bubbles,
   liveBlocks,
   streaming = false,
+  pendingUserText = null,
   toolCatalog,
   liveTokensPerSecond = null,
   lastRunTokensPerSecond = null,
@@ -72,8 +80,9 @@ const BubbleList = ({
   empty,
 }: BubbleListProps) => {
   const hasLive = liveBlocks !== undefined && (streaming || liveBlocks.length > 0);
+  const hasPendingUser = pendingUserText !== null && pendingUserText.trim() !== '';
 
-  if (bubbles.length === 0 && !hasLive) return <>{empty ?? null}</>;
+  if (bubbles.length === 0 && !hasLive && !hasPendingUser) return <>{empty ?? null}</>;
 
   // The speed of the last finished run belongs to the newest persisted assistant bubble — but
   // only while no newer run is streaming (the hook resets it when one starts).
@@ -109,6 +118,14 @@ const BubbleList = ({
           />
         ),
       )}
+      {hasPendingUser && (
+        <UserBubble
+          key="user-pending"
+          // No createdAt: the persisted row's timestamp replaces this bubble at the end-of-run
+          // refetch anyway, and a client-side clock would only pretend to be it.
+          text={pendingUserText ?? ''}
+        />
+      )}
       {hasLive && (
         <AssistantBubble
           key="assistant-live"
diff --git a/web/src/pages/ai/components/ChatThread.tsx b/web/src/pages/ai/components/ChatThread.tsx
index e954163e5..40cecda1e 100644
--- a/web/src/pages/ai/components/ChatThread.tsx
+++ b/web/src/pages/ai/components/ChatThread.tsx
@@ -53,6 +53,12 @@ export interface ChatThreadProps {
   liveBlocks?: RenderBlock[];
   /** True while that run is streaming. */
   streaming?: boolean;
+  /**
+   * Optimistic text of the question the run in flight was admitted with. The persisted transcript
+   * only gains its user row at the end-of-run refetch, so until then the question is drawn from
+   * this copy — otherwise the operator's own message stays invisible for the whole run.
+   */
+  pendingUserText?: string | null;
   /** Catalog from `listTools()`, so tool blocks can show a risk level. */
   toolCatalog?: readonly McpTool[];
   /** Estimated speed of the run in flight, shown live on the streaming bubble. */
@@ -79,6 +85,7 @@ const ChatThread = ({
   bubbles,
   liveBlocks,
   streaming = false,
+  pendingUserText = null,
   toolCatalog,
   liveTokensPerSecond = null,
   lastRunTokensPerSecond = null,
@@ -137,8 +144,12 @@ const ChatThread = ({
   }, [followToBottom, resetKey]);
 
   // Follow the stream only while the reader is already at the bottom. The first arrival after a
-  // reset positions instantly (the transcript just loaded, there is nothing to animate over);
-  // later arrivals follow smoothly.
+  // reset positions instantly (the transcript just loaded, there is nothing to animate over).
+  // While streaming this effect re-runs once per animation frame, so the follow must be instant:
+  // a fresh `smooth` scroll started on EVERY frame stacks dozens of concurrent scroll animations
+  // that fight each other and the compositor — that is the visible jank of a long answer. A
+  // per-frame instant jump is indistinguishable from smooth at display rate; the smooth behaviour
+  // is kept for discrete arrivals (a refetched transcript growing while idle).
   useEffect(() => {
     if (!atBottomRef.current) return;
     if (!positionedRef.current) {
@@ -146,8 +157,8 @@ const ChatThread = ({
       followToBottom('auto');
       return;
     }
-    followToBottom('smooth');
-  }, [bubbles, followToBottom, liveBlocks]);
+    followToBottom(streaming ? 'auto' : 'smooth');
+  }, [bubbles, followToBottom, liveBlocks, streaming]);
 
   // Count what arrives while the reader is away: one per message, not one per streamed token.
   // Only a genuine increase in bubble count is a new message — a streaming failure that removes
@@ -184,6 +195,7 @@ const ChatThread = ({
             bubbles={bubbles}
             liveBlocks={liveBlocks}
             streaming={streaming}
+            pendingUserText={pendingUserText}
             toolCatalog={toolCatalog}
             liveTokensPerSecond={liveTokensPerSecond}
             lastRunTokensPerSecond={lastRunTokensPerSecond}
diff --git a/web/src/pages/ai/components/Composer.tsx b/web/src/pages/ai/components/Composer.tsx
index 61c6f1931..6587878fb 100644
--- a/web/src/pages/ai/components/Composer.tsx
+++ b/web/src/pages/ai/components/Composer.tsx
@@ -24,7 +24,7 @@ import {
   type ReactNode,
   type RefObject,
 } from 'react';
-import { Button, Progress, Select, Tag, Tooltip, theme } from 'antd';
+import { Button, Flex, Progress, Select, Tag, Tooltip, theme } from 'antd';
 import {
   BookOpen,
   CaretDown,
@@ -333,7 +333,9 @@ const Composer = ({
           }
         >
           <div className="flex items-center justify-between gap-3 px-3 pt-2.5">
-            <div className="flex flex-1 min-w-0 items-center gap-2">
+            {/* Wraps on narrow viewports: the selectors carry min-widths and the provider status
+                never shrinks, so without wrap the row overflows the panel instead of reflowing. */}
+            <div className="flex flex-1 min-w-0 items-center gap-2 flex-wrap">
               <span className="shrink-0 text-gray-500" style={{ fontSize: '0.893rem' }}>
                 {t('ai.composer.modelLabel')}
               </span>
@@ -519,7 +521,11 @@ const Composer = ({
                   </div>
                 </div>
                 <div className="shrink-0 flex items-center gap-2">
-                  {showContextBar && (
+                  {/* Context-usage meter, the pattern the AI products converged on (ChatGPT's
+                      "Context left" pill, Cursor's composer meter): a mini bar PLUS the percent
+                      number — a bare bar is unreadable at a glance — hidden while the conversation
+                      is still under 1% of the window so a fresh chat carries no noise. */}
+                  {showContextBar && contextPercent >= 1 && (
                     <Tooltip
                       title={t('ai.composer.contextUsage', {
                         used: contextTokens.toLocaleString(),
@@ -527,15 +533,30 @@ const Composer = ({
                         percent: contextPercent,
                       })}
                     >
-                      <div data-testid="ai-context-bar" style={{ width: 96 }}>
+                      <Flex
+                        align="center"
+                        gap={6}
+                        data-testid="ai-context-bar"
+                        style={{ cursor: 'default' }}
+                      >
                         <Progress
                           percent={contextPercent}
                           showInfo={false}
-                          strokeWidth={10}
+                          strokeWidth={8}
                           strokeColor={contextColor}
-                          style={{ margin: 0 }}
+                          trailColor={token.colorFillSecondary}
+                          style={{ margin: 0, width: 56 }}
                         />
-                      </div>
+                        <span
+                          style={{
+                            fontSize: 13,
+                            color: token.colorTextSecondary,
+                            fontVariantNumeric: 'tabular-nums',
+                          }}
+                        >
+                          {contextPercent}%
+                        </span>
+                      </Flex>
                     </Tooltip>
                   )}
                   <SendStopButton
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx b/web/src/pages/ai/components/ConversationListModal.tsx
index 1624f5f3c..c91eccabe 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -29,11 +29,11 @@ import {
   Typography,
   message,
 } from 'antd';
-import { Trash } from '@phosphor-icons/react';
+import { Archive, TrayArrowUp, Trash } from '@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
 import { useLang } from '../../../i18n/LangContext';
 import type { AiConversationListItemVO, RunStatus } from '../../../api/aiEvents';
-import { deleteConversation } from '../../../api/aiConversations';
+import { deleteConversation, updateConversation } from '../../../api/aiConversations';
 import { useConversationList } from '../hooks/useConversationList';
 import { formatUtcDateTime } from '../../../utils/format';
 import { tableScrollX } from '../../../utils/table';
@@ -119,7 +119,7 @@ const COLUMN_WIDTHS = {
   instance: 120,
   status: 106,
   updatedAt: 176,
-  action: 56,
+  action: 88,
 } as const;
 
 const TABLE_SCROLL_X = tableScrollX(
@@ -169,12 +169,35 @@ const ConversationListPanel = ({
   const list = useConversationList();
   const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
   const [deletingIds, setDeletingIds] = useState<readonly number[]>([]);
+  const [archivingIds, setArchivingIds] = useState<readonly number[]>([]);
   const deleting = deletingIds.length > 0;
   // Pulled out of `list` for the callback below: the hook returns a fresh object every render, so
   // depending on it would rebuild `columns` — and with it every cell — on each one.
-  const { items, page, setPage, reload } = list;
+  const { items, page, setPage, reload, archived } = list;
   const rowCount = items.length;
 
+  /**
+   * Flips the row's archived flag (PATCH) and reloads the scope: an archived conversation leaves
+   * the active list and the other way round, so the row disappearing IS the feedback — the toast
+   * only names what happened. Archiving the conversation currently on screen is allowed: the
+   * transcript stays open, the flag only files it out of the active list.
+   */
+  const toggleArchive = useCallback(
+    async (id: number) => {
+      setArchivingIds((ids) => [...ids, id]);
+      try {
+        await updateConversation(id, { archived: !archived });
+        message.success(archived ? t('ai.list.unarchivedToast') : t('ai.list.archivedToast'));
+        reload();
+      } catch {
+        message.error(t('ai.list.archiveFailed'));
+      } finally {
+        setArchivingIds((ids) => ids.filter((entry) => entry !== id));
+      }
+    },
+    [archived, reload, t],
+  );
+
   /**
    * Hard-deletes conversations one request at a time.
    *
@@ -331,28 +354,48 @@ const ConversationListPanel = ({
         key: 'action',
         width: COLUMN_WIDTHS.action,
         render: (_: unknown, row) => (
-          <Popconfirm
-            title={t('ai.list.deleteConfirm')}
-            description={t('ai.list.deleteHint')}
-            okText={t('common.delete')}
-            cancelText={t('common.cancel')}
-            okButtonProps={{ danger: true }}
-            onConfirm={() => void remove([row.id])}
-          >
+          <Flex align="center" gap={0}>
             <Button
               type="text"
               size="small"
-              danger
-              loading={deletingIds.includes(row.id)}
-              aria-label={t('ai.list.deleteAria', { title: row.title })}
-              data-testid={`ai-conversation-row-delete-${row.id}`}
-              icon={<Trash size={16} />}
+              loading={archivingIds.includes(row.id)}
+              aria-label={
+                archived
+                  ? t('ai.list.unarchiveAria', { title: row.title })
+                  : t('ai.list.archiveAria', { title: row.title })
+              }
+              title={
+                archived
+                  ? t('ai.list.unarchiveAria', { title: row.title })
+                  : t('ai.list.archiveAria', { title: row.title })
+              }
+              data-testid={`ai-conversation-row-archive-${row.id}`}
+              icon={archived ? <TrayArrowUp size={16} /> : <Archive size={16} />}
+              onClick={() => void toggleArchive(row.id)}
             />
-          </Popconfirm>
+            <Popconfirm
+              title={t('ai.list.deleteConfirm')}
+              description={t('ai.list.deleteHint')}
+              okText={t('common.delete')}
+              cancelText={t('common.cancel')}
+              okButtonProps={{ danger: true }}
+              onConfirm={() => void remove([row.id])}
+            >
+              <Button
+                type="text"
+                size="small"
+                danger
+                loading={deletingIds.includes(row.id)}
+                aria-label={t('ai.list.deleteAria', { title: row.title })}
+                data-testid={`ai-conversation-row-delete-${row.id}`}
+                icon={<Trash size={16} />}
+              />
+            </Popconfirm>
+          </Flex>
         ),
       },
     ],
-    [activeConversationId, deletingIds, onSelect, remove, t],
+    [activeConversationId, archivingIds, archived, deletingIds, onSelect, remove, t, toggleArchive],
   );
 
   return (
diff --git a/web/src/pages/ai/components/SendStopButton.tsx b/web/src/pages/ai/components/SendStopButton.tsx
index 42f15dd53..42ba20d76 100644
--- a/web/src/pages/ai/components/SendStopButton.tsx
+++ b/web/src/pages/ai/components/SendStopButton.tsx
@@ -21,7 +21,7 @@ import { useLang } from '../../../i18n/LangContext';
 /**
  * The single send/stop slot of the composer.
  *
- * ONE `<button>` that morphs between three states, always mounted, always the same 36x36 circular
+ * ONE `<button>` that morphs between three states, always mounted, always the same 32x32 circular
  * box. The page it replaces mounted a SECOND antd `<Button danger>停止生成</Button>` next to the send
  * button while a run streamed, which pushed the rest of the toolbar sideways on every turn — the
  * regression `SendStopButton.test.tsx` guards with "exactly one button in all three states".
@@ -84,9 +84,9 @@ const SendStopButton = ({ state, canSend, onSend, onStop }: SendStopButtonProps)
         // `stopping` is disabled, so the click cannot reach this branch.
       }}
     >
-      {state === 'send' && <ArrowUp size={19} weight="bold" />}
-      {state === 'stop' && <Stop size={16} weight="fill" />}
-      {state === 'stopping' && <CircleNotch size={18} weight="bold" className="ai-icon-spin" />}
+      {state === 'send' && <ArrowUp size={17} weight="bold" />}
+      {state === 'stop' && <Stop size={14} weight="fill" />}
+      {state === 'stopping' && <CircleNotch size={16} weight="bold" className="ai-icon-spin" />}
     </button>
   );
 };
diff --git a/web/src/pages/ai/components/UserBubble.tsx b/web/src/pages/ai/components/UserBubble.tsx
index 29f26972a..5bd9baf89 100644
--- a/web/src/pages/ai/components/UserBubble.tsx
+++ b/web/src/pages/ai/components/UserBubble.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { memo } from 'react';
 import { Flex, theme } from 'antd';
 import useAuthStore from '../../../stores/authStore';
 import { formatUtcDateTime } from '../../../utils/format';
@@ -98,4 +99,4 @@ const UserBubble = ({ text, createdAt }: UserBubbleProps) => {
   );
 };
 
-export default UserBubble;
+export default memo(UserBubble);
diff --git a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
index a1d0f7773..3095f8cec 100644
--- a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
@@ -19,7 +19,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { fireEvent, render, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { LangProvider } from '../../../../i18n/LangContext';
-import type { Bubble } from '../../render/blocks';
+import type { Bubble, RenderBlock } from '../../render/blocks';
 import { appendText } from '../../render/blocks';
 import ChatThread from '../ChatThread';
 
@@ -75,13 +75,21 @@ function placeReader(distance: number) {
   return container;
 }
 
-function renderThread(props: { bubbles?: Bubble[]; streaming?: boolean; resetKey?: number }) {
+function renderThread(props: {
+  bubbles?: Bubble[];
+  streaming?: boolean;
+  resetKey?: number;
+  pendingUserText?: string | null;
+  liveBlocks?: RenderBlock[];
+}) {
   return render(
     <LangProvider>
       <ChatThread
         bubbles={props.bubbles ?? []}
         streaming={props.streaming}
         resetKey={props.resetKey}
+        pendingUserText={props.pendingUserText}
+        liveBlocks={props.liveBlocks}
       />
     </LangProvider>,
   );
@@ -108,6 +116,67 @@ describe('ChatThread', () => {
     expect(screen.queryByTestId('ai-thread-jump-to-latest')).not.toBeInTheDocument();
   });
 
+  it('followsInstantlyWhileStreamingSoFramesDoNotStackSmoothScrollsTest', () => {
+    const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
+    placeReader(0);
+    rerender(
+      <LangProvider>
+        <ChatThread bubbles={[userBubble('检查集群状态')]} liveBlocks={appendText([], '部分')} streaming />
+      </LangProvider>,
+    );
+    scrollIntoView.mockClear();
+
+    // The next frame of the SAME run: the follow must be instant. A fresh smooth scroll on every
+    // animation frame stacks concurrent scroll animations — the jank of a long streamed answer.
+    rerender(
+      <LangProvider>
+        <ChatThread
+          bubbles={[userBubble('检查集群状态')]}
+          liveBlocks={appendText([], '部分回答,还在继续')}
+          streaming
+        />
+      </LangProvider>,
+    );
+
+    expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto' });
+  });
+
+  it('keepsSmoothFollowForDiscreteArrivalsWhileIdleTest', () => {
+    const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
+    placeReader(0);
+    scrollIntoView.mockClear();
+
+    // A whole persisted answer arrives while no run is streaming: one discrete jump, animated.
+    rerender(
+      <LangProvider>
+        <ChatThread bubbles={[userBubble('检查集群状态'), assistantBubble('集群正常')]} />
+      </LangProvider>,
+    );
+
+    expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
+  });
+
+  it('showsThePendingQuestionImmediatelyWhileTheRunStreamsTest', () => {
+    // The live stream carries no user frame and the persisted transcript only gains the row at
+    // the end-of-run refetch; the optimistic copy is what keeps the operator's own question
+    // visible from the moment they sent it.
+    renderThread({
+      bubbles: [userBubble('第一个问题'), assistantBubble('第一个回答')],
+      liveBlocks: appendText([], '正在回答'),
+      streaming: true,
+      pendingUserText: '第二个问题',
+    });
+
+    expect(screen.getByText('第二个问题')).toBeInTheDocument();
+    expect(screen.getAllByTestId('ai-bubble-user-avatar')).toHaveLength(2);
+    // The pending question sits ahead of the streaming answer.
+    const pending = screen.getByText('第二个问题');
+    const answer = screen.getByText('正在回答');
+    expect(
+      pending.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING,
+    ).toBeTruthy();
+  });
+
   it('stopsFollowingAndOffersAJumpOnceTheReaderScrollsUpTest', () => {
     const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
     scrollIntoView.mockClear();
diff --git a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
index 47d47cd04..a3e4be132 100644
--- a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
@@ -24,6 +24,7 @@ import type { AiConversationListItemVO } from '../../../../api/aiEvents';
 import {
   deleteConversation,
   listConversations,
+  updateConversation,
   type PageResult,
 } from '../../../../api/aiConversations';
 import ConversationListModal from '../ConversationListModal';
@@ -31,6 +32,7 @@ import ConversationListModal from '../ConversationListModal';
 vi.mock('../../../../api/aiConversations', () => ({
   listConversations: vi.fn(),
   deleteConversation: vi.fn(),
+  updateConversation: vi.fn(),
 }));
 
 /**
@@ -55,6 +57,7 @@ vi.mock('../../../../api/aiConversations', () => ({
 
 const listMock = vi.mocked(listConversations);
 const deleteMock = vi.mocked(deleteConversation);
+const updateMock = vi.mocked(updateConversation);
 
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
@@ -154,10 +157,44 @@ describe('ConversationListModal', () => {
     // of its own stub.
     listMock.mockReset();
     deleteMock.mockReset();
+    updateMock.mockReset();
     vi.clearAllMocks();
     localStorage.clear();
   });
 
+  it('archivesAConversationFromTheActiveScopeTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    listMock.mockResolvedValue(page([conversation(7, '检查集群状态')], 1, 1));
+    updateMock.mockResolvedValue({ id: 7 } as never);
+    renderModal();
+    await screen.findByText('检查集群状态');
+    const listCallsBefore = listMock.mock.calls.length;
+
+    await user.click(screen.getByTestId('ai-conversation-row-archive-7'));
+
+    await waitFor(() => expect(updateConversation).toHaveBeenCalledWith(7, { archived: true }));
+    // The scope reloads so the archived row leaves the active list.
+    await waitFor(() => expect(listMock.mock.calls.length).toBeGreaterThan(listCallsBefore));
+  });
+
+  it('unarchivesAConversationFromTheArchivedScopeTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    listMock.mockResolvedValue(page([conversation(7, '检查集群状态')], 1, 1));
+    updateMock.mockResolvedValue({ id: 7 } as never);
+    renderModal();
+    await screen.findByText('检查集群状态');
+
+    await user.click(screen.getByText('已归档'));
+    await waitFor(() =>
+      expect(listConversations).toHaveBeenLastCalledWith(
+        expect.objectContaining({ archived: true }),
+      ),
+    );
+
+    await user.click(screen.getByTestId('ai-conversation-row-archive-7'));
+    await waitFor(() => expect(updateConversation).toHaveBeenCalledWith(7, { archived: false }));
+  });
+
   it('doesNotQueryWhileClosedTest', async () => {
     renderModal({ open: false });
 
diff --git a/web/src/pages/ai/components/blocks/ErrorBlock.tsx b/web/src/pages/ai/components/blocks/ErrorBlock.tsx
index c8f3ea3ad..10f8547a7 100644
--- a/web/src/pages/ai/components/blocks/ErrorBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ErrorBlock.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { memo } from 'react';
 import { Alert, Flex, Tag, theme } from 'antd';
 import type { ErrorBlock as ErrorBlockData } from '../../render/blocks';
 
@@ -62,4 +63,4 @@ const ErrorBlock = ({ block }: ErrorBlockProps) => {
   );
 };
 
-export default ErrorBlock;
+export default memo(ErrorBlock);
diff --git a/web/src/pages/ai/components/blocks/NoticeBlock.tsx b/web/src/pages/ai/components/blocks/NoticeBlock.tsx
index 3cc2c6426..67b77890d 100644
--- a/web/src/pages/ai/components/blocks/NoticeBlock.tsx
+++ b/web/src/pages/ai/components/blocks/NoticeBlock.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { memo } from 'react';
 import InfoBanner from '../../../../components/InfoBanner';
 import { useLang } from '../../../../i18n/LangContext';
 import type { NoticeBlock as NoticeBlockData } from '../../render/blocks';
@@ -47,4 +48,4 @@ const NoticeBlock = ({ block }: NoticeBlockProps) => {
   );
 };
 
-export default NoticeBlock;
+export default memo(NoticeBlock);
diff --git a/web/src/pages/ai/components/blocks/TextBlock.tsx b/web/src/pages/ai/components/blocks/TextBlock.tsx
index 3bc06ec41..e0dfc5274 100644
--- a/web/src/pages/ai/components/blocks/TextBlock.tsx
+++ b/web/src/pages/ai/components/blocks/TextBlock.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { memo } from 'react';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 import type { TextBlock as TextBlockData } from '../../render/blocks';
@@ -30,16 +31,20 @@ import { normalizeAiMarkdown } from '../../render/markdown';
  * The same component renders a live `text_delta` accumulation and a replayed persisted `text` event:
  * both reducers coalesce adjacent text into ONE block, so a streamed answer and the same answer
  * reloaded from the database produce identical DOM.
+ *
+ * Memoised on the block object: the reducers only replace the block that actually changed, so an
+ * unchanged text keeps its identity across streaming ticks and skips the full ReactMarkdown
+ * re-parse — that parse is the single most expensive thing a transcript frame does.
  */
 
 export interface TextBlockProps {
   block: TextBlockData;
 }
 
-const TextBlock = ({ block }: TextBlockProps) => (
+const TextBlock = memo(({ block }: TextBlockProps) => (
   <div className="ai-markdown" data-testid="ai-text-block">
     <ReactMarkdown remarkPlugins={[remarkGfm]}>{normalizeAiMarkdown(block.text)}</ReactMarkdown>
   </div>
-);
+));
 
 export default TextBlock;
diff --git a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
index e6d11947f..4ccbc3df7 100644
--- a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState } from 'react';
+import { memo, useState } from 'react';
 import { theme } from 'antd';
 import { Brain, Sparkle } from '@phosphor-icons/react';
 import { useLang } from '../../../../i18n/LangContext';
@@ -113,4 +113,4 @@ const ThinkingBlock = ({ block, streaming = false, latest = false }: ThinkingBlo
   );
 };
 
-export default ThinkingBlock;
+export default memo(ThinkingBlock);
diff --git a/web/src/pages/ai/components/blocks/ToolBlock.tsx b/web/src/pages/ai/components/blocks/ToolBlock.tsx
index 3797d2bee..d1dd58119 100644
--- a/web/src/pages/ai/components/blocks/ToolBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ToolBlock.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import type { CSSProperties } from 'react';
+import { memo, type CSSProperties } from 'react';
 import { Progress, Tag, theme } from 'antd';
 import { CaretRight, CheckCircle, CircleNotch, XCircle } from '@phosphor-icons/react';
 import { useLang } from '../../../../i18n/LangContext';
@@ -49,6 +49,10 @@ import { formatBytes } from '../../../../utils/format';
  * that merely usually holds serialised JSON. The backend already stripped inline base64 and capped
  * the text at 32 KiB; `outputBytes` keeps the real size and `truncated` says whether that cap bit,
  * which is what the 已截断(N KB) badge reports.
+ *
+ * Memoised on the block object: a run with dozens of tool calls re-renders the page at display
+ * rate while the NEXT call streams, and `withToolResult` only replaces the block it fills — every
+ * finished card keeps its identity and must not reconcile its (up to 32 KiB) `<pre>` again.
  */
 
 /** Risk colour of the tool catalog: L1 reads, L2 previewable changes, L3 destructive changes. */
@@ -258,4 +262,4 @@ const ToolBlock = ({ block, toolCatalog }: ToolBlockProps) => {
   );
 };
 
-export default ToolBlock;
+export default memo(ToolBlock);
diff --git a/web/src/pages/ai/hooks/useAgentRun.ts b/web/src/pages/ai/hooks/useAgentRun.ts
index 63287c61b..03f0c1a57 100644
--- a/web/src/pages/ai/hooks/useAgentRun.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.ts
@@ -108,6 +108,12 @@ export interface UseAgentRunResult {
   liveTokensPerSecond: number | null;
   /** Final speed of the most recent finished run; null when it produced no measurable text. */
   lastRunTokensPerSecond: number | null;
+  /**
+   * The prompt the run in flight was admitted with, or null. The stream carries no user frame and
+   * the persisted transcript only gains the row at the end-of-run refetch, so the caller renders
+   * this as an optimistic user bubble while the run is alive.
+   */
+  pendingUserMessage: string | null;
   /**
    * Send a message and stream the run it starts. Resolves once the terminal frames were processed
    * and the timeline refetch settled. A no-op while another run is in flight.
@@ -150,6 +156,14 @@ export function useAgentRun(
   const [error, setError] = useState('');
   const [lastRunTokensPerSecond, setLastRunTokensPerSecond] = useState<number | null>(null);
   const [liveTokensPerSecond, setLiveTokensPerSecond] = useState<number | null>(null);
+  /**
+   * The prompt the admitted run was started with, or null. The live stream carries no `user`
+   * frame and the persisted transcript is only refetched when the run FINISHES, so without this
+   * optimistic copy the operator's own question stays invisible for the whole run — minutes on a
+   * long tool-heavy answer. Rendered as a user bubble ahead of the live assistant bubble; the
+   * final refetch replaces it with the persisted row in one commit.
+   */
+  const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(null);
 
   const generationRef = useRef(0);
   const streamRequestIdRef = useRef(0);
@@ -262,6 +276,7 @@ export function useAgentRun(
         // Keep the live blocks: with the refetch failed they are the only copy of this answer. When
         // the stream failed too, that message is the informative one — a dead connection explains
         // the truncated answer, a failed reload only explains why it did not move into history.
+        // The optimistic user bubble stays for the same reason: the transcript never got the row.
         if (streamFailure === null) setError(describeThrownMessage(refetchError));
         scheduleTick();
         return;
@@ -271,6 +286,9 @@ export function useAgentRun(
       // The speed of this run is final the moment the stream closes; persist it before the
       // refetch swaps the live bubble for its persisted twin, which is what displays it.
       setLastRunTokensPerSecond(speedTrackerRef.current.tokensPerSecond());
+      // The refetched transcript now renders the persisted user row; drop the optimistic twin in
+      // the same commit so it never paints twice.
+      setPendingUserMessage(null);
       blocksRef.current = [];
       scheduleTick();
     },
@@ -282,6 +300,7 @@ export function useAgentRun(
       targetConversationId: number,
       open: (handlers: RunStreamHandlers, signal: AbortSignal) => Promise<void>,
       knownRunId: number | null = null,
+      userMessage?: string,
     ): Promise<void> => {
       // Double-submit guard: Enter twice in one tick must not admit two runs (the server would
       // reject the second with 409 anyway, but the UI should not even try).
@@ -295,6 +314,8 @@ export function useAgentRun(
         return;
       }
       chatInFlightRef.current = true;
+      // Only `send` carries a prompt; a re-attach finds the user row already in the timeline.
+      if (userMessage !== undefined) setPendingUserMessage(userMessage);
 
       const requestId = ++streamRequestIdRef.current;
       const generation = ++generationRef.current;
@@ -344,8 +365,11 @@ export function useAgentRun(
 
   const send = useCallback(
     (targetConversationId: number, request: AiMessageRequest): Promise<void> =>
-      startStream(targetConversationId, (handlers, signal) =>
-        openRunStream(targetConversationId, request, handlers, signal),
+      startStream(
+        targetConversationId,
+        (handlers, signal) => openRunStream(targetConversationId, request, handlers, signal),
+        null,
+        request.message,
       ),
     [startStream],
   );
@@ -394,6 +418,7 @@ export function useAgentRun(
     setIsStreaming(false);
     setStopRequested(false);
     setError('');
+    setPendingUserMessage(null);
     bump();
   }, [cancelFrame]);
 
@@ -436,6 +461,7 @@ export function useAgentRun(
     error,
     liveTokensPerSecond,
     lastRunTokensPerSecond,
+    pendingUserMessage,
     send,
     attach,
     stop,
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 3e8f57eba..93252f5e4 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useCallback, useRef, useState } from 'react';
+import { useCallback, useMemo, useRef, useState } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
 import { Flex, message, theme } from 'antd';
 import { useLang } from '../../i18n/LangContext';
@@ -127,15 +127,21 @@ const AiPage = () => {
   );
 
   // Estimated context footprint of this conversation — persisted text plus whatever the run in
-  // flight has produced so far — drawn as the composer's context-usage bar. Recomputed on every
-  // render on purpose: the run's renderTick bumps re-render this page as blocks stream in.
-  let contextTokens = 0;
-  for (const item of timeline.items) {
-    const event = item.event;
-    if (event.type === 'text' || event.type === 'user') {
-      contextTokens += estimateTokens(event.text);
+  // flight has produced so far — drawn as the composer's context-usage bar. The persisted half is
+  // memoised on the timeline rows: a streaming tick re-renders this page at display rate, and
+  // re-scanning EVERY persisted answer with the token estimator each frame is pure waste; only
+  // the live blocks of the run in flight are recomputed per render.
+  const persistedTokens = useMemo(() => {
+    let tokens = 0;
+    for (const item of timeline.items) {
+      const event = item.event;
+      if (event.type === 'text' || event.type === 'user') {
+        tokens += estimateTokens(event.text);
+      }
     }
-  }
+    return tokens;
+  }, [timeline.items]);
+  let contextTokens = persistedTokens;
   for (const block of run.blocksRef.current) {
     if (block.kind === 'text') contextTokens += estimateTokens(block.text);
   }
@@ -146,6 +152,7 @@ const AiPage = () => {
         bubbles={timeline.bubbles}
         liveBlocks={run.blocksRef.current}
         streaming={run.isStreaming}
+        pendingUserText={run.pendingUserMessage}
         toolCatalog={tools}
         liveTokensPerSecond={run.liveTokensPerSecond}
         lastRunTokensPerSecond={run.lastRunTokensPerSecond}
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index e53199dbd..004de88c7 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -168,7 +168,7 @@ describe('Clients page', () => {
       address: `10.0.1.${index + 1}:49152`,
     }));
     vi.mocked(connectionsService.listConnections).mockResolvedValue(pagedConnections);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const { container } = renderWithProviders(<ClientsPage />);
 
     await screen.findByText('client-00');
@@ -195,7 +195,7 @@ describe('Clients page', () => {
       { ...connection, clusterName: 'ns-prod', type: 'Consumer' },
     ];
     vi.mocked(connectionsService.listConnections).mockResolvedValue(mixedConnections);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     await screen.findAllByText('order-svc-0@10.0.1.12:49152');
@@ -285,7 +285,7 @@ describe('Clients page', () => {
   });
 
   it('updates statistics when the selected cluster filter changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockImplementation((query) =>
       Promise.resolve(
         query?.clusterId === 'ns-prod'
@@ -330,7 +330,7 @@ describe('Clients page', () => {
   });
 
   it('keeps cluster statistics stable when text search narrows the table', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
     renderWithProviders(<ClientsPage />);
 
@@ -351,7 +351,7 @@ describe('Clients page', () => {
   });
 
   it('keeps incomplete client metadata searchable by address', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockResolvedValue([
       {
         ...connection,
@@ -369,7 +369,7 @@ describe('Clients page', () => {
   });
 
   it('opens details for connections without client id or address', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockResolvedValue([
       {
         ...connection,
@@ -405,7 +405,7 @@ describe('Clients page', () => {
       value: revokeObjectURL,
     });
     const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(connectionsService.listConnections).mockResolvedValue([
       {
         ...connection,
@@ -449,7 +449,7 @@ describe('Clients page', () => {
     });
     vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
     vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     await screen.findByText('order-svc-0@10.0.1.12:49152');
@@ -484,7 +484,7 @@ describe('Clients page', () => {
       value: vi.fn(),
     });
     vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const billingConnections: ClientConnection[] = [
       {
         clientId: 'billing-svc-0@10.0.3.10:49155',
@@ -529,7 +529,7 @@ describe('Clients page', () => {
 
   it('clears the text search and type filter when the nameserver changes', async () => {
     vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     expect(await screen.findByText('order-svc-0@10.0.1.12:49152')).toBeInTheDocument();
@@ -591,7 +591,7 @@ describe('Clients page', () => {
     vi.mocked(connectionsService.listConnections)
       .mockRejectedValueOnce(new Error('Client connection provider is not configured'))
       .mockResolvedValueOnce([connection]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     expect(
@@ -614,7 +614,7 @@ describe('Clients page', () => {
         ? Promise.resolve([connection])
         : Promise.reject(new Error('Cluster ns-audit is unavailable')),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     await screen.findByText('order-svc-0@10.0.1.12:49152');
@@ -631,7 +631,7 @@ describe('Clients page', () => {
   });
 
   it('ignores a stale connection response after switching nameservers', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const stale = deferred<ClientConnection[]>();
     const latest = deferred<ClientConnection[]>();
     vi.mocked(connectionsService.listConnections)
@@ -665,7 +665,7 @@ describe('Clients page', () => {
   });
 
   it('ignores a stale registry response after a retry', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const stale = deferred<ClusterInfo[]>();
     vi.mocked(clusterService.listRegistryClusters)
       .mockRejectedValueOnce(new Error('Unable to load registry clusters'))
@@ -689,7 +689,7 @@ describe('Clients page', () => {
     vi.mocked(clusterService.listRegistryClusters)
       .mockRejectedValueOnce(new Error('Unable to load registry clusters'))
       .mockResolvedValueOnce(registryClusters);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     expect(await screen.findByText('Unable to load registry clusters')).toBeInTheDocument();
@@ -703,7 +703,7 @@ describe('Clients page', () => {
   });
 
   it('opens a client detail dialog from the connection table', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClientsPage />);
 
     const row = await screen.findByRole('row', { name: /order-svc-0@10\.0\.1\.12:49152/ });
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 64e452eb5..fdef6239b 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -368,7 +368,7 @@ describe('Cluster page', () => {
   }, 10000);
 
   it('opens proxy detail dialog from the proxy table', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClusterPage />);
 
     await user.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
@@ -385,7 +385,7 @@ describe('Cluster page', () => {
   });
 
   it('previews broker config changes before submitting the update', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClusterPage />);
 
     const brokerRow = await screen.findByRole('row', { name: /10\.101\.2\.11:10911/ });
@@ -414,7 +414,7 @@ describe('Cluster page', () => {
   });
 
   it('keeps the latest broker config preview after a superseded response finishes last', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const stalePreview = deferred<ClusterConfigPreviewResult>();
     const latestPreview = deferred<ClusterConfigPreviewResult>();
     clusterServiceMocks.previewClusterConfig
@@ -486,7 +486,7 @@ describe('Cluster page', () => {
   });
 
   it('keeps cluster tabs usable when address fields are missing', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const submitSearch = async (placeholder: string, value: string) => {
       const input = screen.getByPlaceholderText(placeholder);
       await user.type(input, value);
@@ -521,7 +521,7 @@ describe('Cluster page', () => {
   });
 
   it('creates and deletes nameserver registry entries', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClusterPage />);
     await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
     expect(await screen.findByText('rocketmq1-nameserver:9876')).toBeInTheDocument();
@@ -575,7 +575,7 @@ describe('Cluster page', () => {
 
   it('localizes the NameServer address guidance', async () => {
     localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ClusterPage />);
     await user.click(screen.getByRole('tab', { name: /NameServer/ }));
     await user.click(screen.getByRole('button', { name: /New NameServer/ }));
@@ -590,7 +590,7 @@ describe('Cluster page', () => {
   });
 
   it('opens NameServer config drift details from a registry row', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
         id: 10,
@@ -676,7 +676,7 @@ describe('Cluster page', () => {
   });
 
   it('omits instanceId for NameServer config drift when no instance owns the registry cluster', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     clusterServiceMocks.listRegistryClusters.mockResolvedValue([
       {
         ...buildCluster(),
@@ -701,7 +701,7 @@ describe('Cluster page', () => {
   });
 
   it('opens Broker config drift details from a broker row', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
         id: 10,
@@ -788,7 +788,7 @@ describe('Cluster page', () => {
   });
 
   it('reports Broker config drift load failures without closing the dialog', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
     clusterServiceMocks.getBrokerConfigDiff.mockRejectedValueOnce(new Error('failure'));
     renderWithProviders(<ClusterPage />);
@@ -805,7 +805,7 @@ describe('Cluster page', () => {
   });
 
   it('renders a failed NameServer config diff as an error with retry instead of the loading banner', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
     clusterServiceMocks.listRegistryClusters.mockResolvedValue([
       {
@@ -848,7 +848,7 @@ describe('Cluster page', () => {
   });
 
   it('renders a failed Broker config diff as an error with retry instead of the loading banner', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
     clusterServiceMocks.getBrokerConfigDiff
       .mockRejectedValueOnce(new Error('broker unreachable'))
diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx
index 256cb8cb5..01c86ca37 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
 import {
   Alert,
   Button,
@@ -63,6 +63,20 @@ const typeConfig: Record<string, { color: string; label: string }> = {
   Consumer: { color: 'green', label: 'Consumer' },
 };
 
+/**
+ * One clipped line inside a diagnostics-table cell. The project-wide
+ * `.ant-table-cell { white-space: nowrap }` keeps long values (trace-producer resource names,
+ * client ids, risk descriptions) from wrapping, so without an explicit ellipsis they spill over
+ * the neighbouring column; each line clips itself and carries its full text in a `title`.
+ */
+const diagnosticCellLine: CSSProperties = {
+  display: 'block',
+  maxWidth: '100%',
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  whiteSpace: 'nowrap',
+};
+
 const protocolConfig: Record<string, { color: string; label: string }> = {
   gRPC: { color: 'green', label: 'gRPC' },
   Remoting: { color: 'blue', label: 'Remoting' },
@@ -531,11 +545,20 @@ const ClientsPage = () => {
       title: t('clients.groupOrTopic'),
       key: 'resource',
       width: 220,
+      // Unbounded resource names (_INNER_TRACE_PRODUCER-…-CONSUME-1) must clip inside the
+      // cell: the project-wide `.ant-table-cell { white-space: nowrap }` otherwise lets them
+      // spill over the neighbouring column. Each line ellipsises on its own with a native
+      // title tooltip, the ConversationListModal title-column idiom.
+      ellipsis: { showTitle: false },
       render: (_: unknown, record) => (
-        <Space direction="vertical" size={2}>
-          <Text strong>{record.resource}</Text>
-          <Text type="secondary">{record.type}</Text>
-        </Space>
+        <div style={{ minWidth: 0 }}>
+          <Text strong style={diagnosticCellLine} title={record.resource}>
+            {record.resource}
+          </Text>
+          <Text type="secondary" style={diagnosticCellLine}>
+            {record.type}
+          </Text>
+        </div>
       ),
     },
     {
@@ -610,11 +633,18 @@ const ClientsPage = () => {
       title: t('clients.diagnosticIssue'),
       key: 'issue',
       width: 260,
+      // The description is a full sentence; clipped to one line with the full text on hover,
+      // never wrapped (project table rule) and never spilling into the Client ID column.
+      ellipsis: { showTitle: false },
       render: (_: unknown, record) => (
-        <Space direction="vertical" size={2}>
-          <Text strong>{record.title}</Text>
-          <Text type="secondary">{record.description}</Text>
-        </Space>
+        <div style={{ minWidth: 0 }}>
+          <Text strong style={diagnosticCellLine} title={record.title}>
+            {record.title}
+          </Text>
+          <Text type="secondary" style={diagnosticCellLine} title={record.description}>
+            {record.description}
+          </Text>
+        </div>
       ),
     },
     {
@@ -622,9 +652,12 @@ const ClientsPage = () => {
       dataIndex: 'clientId',
       key: 'clientId',
       width: 180,
+      ellipsis: { showTitle: false },
       render: (clientId?: string) =>
         clientId ? (
-          <Text style={{ fontFamily: 'monospace' }}>{clientId}</Text>
+          <Text style={{ ...diagnosticCellLine, fontFamily: 'monospace' }} title={clientId}>
+            {clientId}
+          </Text>
         ) : (
           <Text type="secondary">-</Text>
         ),
@@ -634,6 +667,7 @@ const ClientsPage = () => {
       dataIndex: 'resource',
       key: 'resource',
       width: 160,
+      ellipsis: true,
       render: (resource?: string) => resource || '-',
     },
     {
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 375826711..c768612f5 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -38,6 +38,8 @@ import {
   Alert,
   Spin,
   message,
+  Row,
+  Col,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
@@ -1389,15 +1391,20 @@ const ClusterPage = () => {
             confirmLoading={configSubmitting}
             width={720}
           >
-            <Space style={{ marginBottom: 16 }}>
+            <Flex justify="flex-end" style={{ marginBottom: 12 }}>
               <Button
+                size="small"
                 icon={<EyeOutlined />}
                 loading={configPreviewLoading}
                 onClick={() => void handleConfigPreview()}
               >
                 {t('cluster.configPreview')}
               </Button>
-            </Space>
+            </Flex>
+            {/* Two-column grid: the eight fields pair up into four rows, halving the modal
+                height. Related controls sit side by side (flush + retention, size + permission,
+                the queue pair, the two auto-create switches) so the form reads as groups
+                instead of a corridor of labels. */}
             <Form
               form={configForm}
               layout="vertical"
@@ -1407,45 +1414,83 @@ const ClusterPage = () => {
                 setConfigPreviewLoading(false);
               }}
             >
-              <Form.Item label={t('cluster.flushDiskType')} name="flushDiskType">
-                <Radio.Group>
-                  <Radio value="SYNC_FLUSH">{t('cluster.syncFlush')}</Radio>
-                  <Radio value="ASYNC_FLUSH">{t('cluster.asyncFlush')}</Radio>
-                </Radio.Group>
-              </Form.Item>
-              <Form.Item
-                label={t('cluster.autoCreateTopic')}
-                name="autoCreateTopicEnable"
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              <Form.Item
-                label={t('cluster.autoCreateSubGroup')}
-                name="autoCreateSubscriptionGroup"
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-              <Form.Item label={t('cluster.maxMessageSize')} name="maxMessageSizeMB">
-                <InputNumber min={1} max={128} style={{ width: '100%' }} />
-              </Form.Item>
-              <Form.Item label={t('cluster.fileReservedTime')} name="fileReservedTime">
-                <InputNumber min={1} max={720} style={{ width: '100%' }} />
-              </Form.Item>
-              <Form.Item label={t('cluster.writeQueues')} name="writeQueueNums">
-                <InputNumber min={1} max={256} style={{ width: '100%' }} />
-              </Form.Item>
-              <Form.Item label={t('cluster.readQueues')} name="readQueueNums">
-                <InputNumber min={1} max={256} style={{ width: '100%' }} />
-              </Form.Item>
-              <Text type="secondary" style={{ display: 'block', marginTop: -16, marginBottom: 16 }}>
-                RocketMQ Broker uses one default Topic queue count; read and write values must
-                match.
-              </Text>
-              <Form.Item label={t('cluster.brokerPermission')} name="brokerPermission">
-                <InputNumber min={0} max={7} style={{ width: '100%' }} />
-              </Form.Item>
+              <Row gutter={16}>
+                <Col span={12}>
+                  <Form.Item label={t('cluster.flushDiskType')} name="flushDiskType">
+                    <Radio.Group>
+                      <Radio value="SYNC_FLUSH">{t('cluster.syncFlush')}</Radio>
+                      <Radio value="ASYNC_FLUSH">{t('cluster.asyncFlush')}</Radio>
+                    </Radio.Group>
+                  </Form.Item>
+                </Col>
+                <Col span={12}>
+                  <Form.Item label={t('cluster.fileReservedTime')} name="fileReservedTime">
+                    <InputNumber min={1} max={720} style={{ width: '100%' }} />
+                  </Form.Item>
+                </Col>
+              </Row>
+              <Row gutter={16}>
+                <Col span={12}>
+                  <Form.Item label={t('cluster.maxMessageSize')} name="maxMessageSizeMB">
+                    <InputNumber min={1} max={128} style={{ width: '100%' }} />
+                  </Form.Item>
+                </Col>
+                <Col span={12}>
+                  {/* Named options instead of a raw 0-7 number: the permission is a bitfield
+                      and only 6/4/2/0 carry meaning for an operator. An unusual persisted
+                      value (e.g. 7) still renders — the Select falls back to showing it raw. */}
+                  <Form.Item label={t('cluster.brokerPermission')} name="brokerPermission">
+                    <Select
+                      options={[
+                        { value: 6, label: t('cluster.permRW') },
+                        { value: 4, label: t('cluster.permR') },
+                        { value: 2, label: t('cluster.permW') },
+                        { value: 0, label: t('cluster.permNone') },
+                      ]}
+                    />
+                  </Form.Item>
+                </Col>
+              </Row>
+              <Row gutter={16}>
+                <Col span={12}>
+                  <Form.Item
+                    label={t('cluster.writeQueues')}
+                    name="writeQueueNums"
+                    tooltip={t('cluster.queueMatchHint')}
+                  >
+                    <InputNumber min={1} max={256} style={{ width: '100%' }} />
+                  </Form.Item>
+                </Col>
+                <Col span={12}>
+                  <Form.Item
+                    label={t('cluster.readQueues')}
+                    name="readQueueNums"
+                    tooltip={t('cluster.queueMatchHint')}
+                  >
+                    <InputNumber min={1} max={256} style={{ width: '100%' }} />
+                  </Form.Item>
+                </Col>
+              </Row>
+              <Row gutter={16}>
+                <Col span={12}>
+                  <Form.Item
+                    label={t('cluster.autoCreateTopic')}
+                    name="autoCreateTopicEnable"
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                </Col>
+                <Col span={12}>
+                  <Form.Item
+                    label={t('cluster.autoCreateSubGroup')}
+                    name="autoCreateSubscriptionGroup"
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                </Col>
+              </Row>
             </Form>
             {renderConfigPreview()}
           </Modal>
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 2df36867a..1533a4f5b 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -179,7 +179,7 @@ describe('DashboardPage', () => {
 
   it('exposes traffic insight metrics in the expanded cluster health row', async () => {
     vi.mocked(dashboardService.getDashboard).mockResolvedValue(trafficDashboard());
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('traffic-a');
@@ -212,7 +212,7 @@ describe('DashboardPage', () => {
     vi.mocked(dashboardService.getDashboard)
       .mockResolvedValueOnce(dashboard('initial-cluster'))
       .mockReturnValueOnce(instanceA.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('initial-cluster');
@@ -235,7 +235,7 @@ describe('DashboardPage', () => {
       .mockResolvedValueOnce(dashboard('initial-cluster'))
       .mockReturnValueOnce(instanceA.promise)
       .mockReturnValueOnce(instanceB.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('initial-cluster');
@@ -261,7 +261,7 @@ describe('DashboardPage', () => {
 
   it('preserves the selected instance when navigating to the cluster page', async () => {
     vi.mocked(dashboardService.getDashboard).mockResolvedValue(dashboard('instance-a-cluster'));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(
       <>
         <DashboardPage />
@@ -308,7 +308,7 @@ describe('DashboardPage', () => {
       },
     ]);
     vi.mocked(dashboardService.getDashboard).mockResolvedValue(dashboard('apache-cluster'));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('apache-cluster');
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx b/web/src/pages/instance/__tests__/AclPage.test.tsx
index 0e07ec39a..b8219c964 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -132,7 +132,7 @@ describe('ACL page', () => {
   });
 
   it('reloads the server rule page after deleting one ACL rule', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -176,7 +176,7 @@ describe('ACL page', () => {
   });
 
   it('shows a non-copyable placeholder when an ACL user has no access key', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.pageAclUsers).mockResolvedValue({
       items: [
         {
@@ -203,7 +203,7 @@ describe('ACL page', () => {
   });
 
   it('clamps rules back to a valid page when the current page becomes empty', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const ruleItems = (count: number) =>
       Array.from({ length: count }, (_, index) => ({
         id: index + 1,
@@ -246,7 +246,7 @@ describe('ACL page', () => {
   });
 
   it('closes an ACL rule dialog when switching to another instance', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       {
         id: 1,
@@ -295,7 +295,7 @@ describe('ACL page', () => {
   });
 
   it('keeps users available when loading rules fails', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.listAclRules).mockRejectedValue(new Error('rules unavailable'));
     renderWithProviders(<AclPage />);
 
@@ -313,7 +313,7 @@ describe('ACL page', () => {
   });
 
   it('uses Tencent role names for role-backed ACL rules and users', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       {
         id: 21,
@@ -425,7 +425,7 @@ describe('ACL page', () => {
   });
 
   it('renders backend users on the user tab', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<AclPage />);
 
     await user.click(await screen.findByText('用户管理'));
@@ -435,7 +435,7 @@ describe('ACL page', () => {
   });
 
   it('shows missing backend timestamps as unavailable', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.listAclRules).mockResolvedValue({
       items: [
         {
@@ -483,7 +483,7 @@ describe('ACL page', () => {
   });
 
   it('does not submit masked credentials when editing a user', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.updateAclUser).mockResolvedValue({
       id: 11,
       username: 'remote-admin',
@@ -519,7 +519,7 @@ describe('ACL page', () => {
 
   it('ignores duplicate admin toggles while an update is pending', async () => {
     vi.mocked(aclService.updateAclUser).mockImplementation(() => new Promise(() => {}));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<AclPage />);
 
     await user.click(await screen.findByText('用户管理'));
@@ -533,7 +533,7 @@ describe('ACL page', () => {
   });
 
   it('does not submit masked credentials when toggling admin', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.updateAclUser).mockResolvedValue({
       id: 11,
       username: 'remote-admin',
@@ -563,7 +563,7 @@ describe('ACL page', () => {
   });
 
   it('creates a user with the selected cluster scope', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.createAclUser).mockResolvedValue({
       id: 13,
       username: 'orders-service',
@@ -598,7 +598,7 @@ describe('ACL page', () => {
   });
 
   it('reloads the server user page after creating an ACL user', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.createAclUser).mockResolvedValue({
       id: 13,
       username: 'orders-service',
@@ -628,7 +628,7 @@ describe('ACL page', () => {
   });
 
   it('reloads the server user page after deleting one ACL user', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -647,7 +647,7 @@ describe('ACL page', () => {
   });
 
   it('replaces the cluster scope of an existing user', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.updateAclUser).mockResolvedValue({
       id: 11,
       username: 'remote-admin',
@@ -680,7 +680,7 @@ describe('ACL page', () => {
   });
 
   it('examines the broker cluster ACL config', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
       clusterId: 'DefaultCluster',
       aclEnabled: true,
@@ -727,7 +727,7 @@ describe('ACL page', () => {
       deferred<Awaited<ReturnType<typeof aclService.examineBrokerClusterAclConfig>>>();
     const successSpy = vi.spyOn(message, 'success').mockImplementation(vi.fn());
     const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.examineBrokerClusterAclConfig)
       .mockReturnValueOnce(firstExamine.promise)
       .mockReturnValueOnce(secondExamine.promise)
@@ -823,7 +823,7 @@ describe('ACL page', () => {
     const secondReveal = deferred<CredentialResponse>();
     const thirdReveal = deferred<CredentialResponse>();
     const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.getAclUserCredentials)
       .mockReturnValueOnce(firstReveal.promise)
       .mockReturnValueOnce(secondReveal.promise)
@@ -897,7 +897,7 @@ describe('ACL page', () => {
   });
 
   it('renders ACL risk diagnostics for the examined cluster config', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
       clusterId: 'DefaultCluster',
       aclEnabled: true,
@@ -934,7 +934,7 @@ describe('ACL page', () => {
   });
 
   it('creates a plain access account', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
       accessKey: 'new-svc',
       admin: false,
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 1873a3993..6cf189487 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -313,7 +313,7 @@ describe('Consumer page', () => {
   });
 
   it('clears selected consumer groups when the search scope changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -332,7 +332,7 @@ describe('Consumer page', () => {
   });
 
   it('clamps back to a valid page when the current page becomes empty after a delete', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     let call = 0;
     vi.mocked(consumerService.listConsumerGroupPage).mockImplementation(async (params) => {
       call += 1;
@@ -361,7 +361,7 @@ describe('Consumer page', () => {
   });
 
   it('reloads the server page after deleting one consumer group', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -384,7 +384,7 @@ describe('Consumer page', () => {
   });
 
   it('moves back from an emptied last consumer group page after batch deletion', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -440,7 +440,7 @@ describe('Consumer page', () => {
   });
 
   it('keeps failed consumer groups selected after a partially successful batch deletion', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -479,7 +479,7 @@ describe('Consumer page', () => {
   });
 
   it('submits the canonical global delivery order type', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -528,7 +528,7 @@ describe('Consumer page', () => {
       void config.onOk?.();
       return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await screen.findByText('remote-cg');
@@ -561,7 +561,7 @@ describe('Consumer page', () => {
   });
 
   it('downloads all consumer groups matching the current filters when exporting', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
     let exportedBlob: Blob | undefined;
     vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
@@ -644,7 +644,7 @@ describe('Consumer page', () => {
   });
 
   it('loads subscriptions and progress when opening a group', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -675,7 +675,7 @@ describe('Consumer page', () => {
     vi.mocked(consumerService.getConsumerProgress).mockRejectedValue(
       new Error('broker unreachable'),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -754,7 +754,7 @@ describe('Consumer page', () => {
         diffTotal: 1_100,
       },
     ]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -785,7 +785,7 @@ describe('Consumer page', () => {
         consistency: '一致',
       },
     ]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -819,7 +819,7 @@ describe('Consumer page', () => {
     vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
       groupPage([{ ...group, instanceId: 'instance-a' }]),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -836,7 +836,7 @@ describe('Consumer page', () => {
   });
 
   it('previews queue impact before resetting consumer offsets', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -892,7 +892,7 @@ describe('Consumer page', () => {
   });
 
   it('invalidates the reset preview when reset parameters change', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -918,7 +918,7 @@ describe('Consumer page', () => {
   });
 
   it('supports skipping accumulation by resetting to the latest offsets', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -981,7 +981,7 @@ describe('Consumer page', () => {
         },
       ],
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -1033,7 +1033,7 @@ describe('Consumer page', () => {
         size: params?.pageSize ?? 20,
       }),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />, '/instance/instance-a/consumer');
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1136,7 +1136,7 @@ describe('Consumer page', () => {
         },
       ]),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1171,7 +1171,7 @@ describe('Consumer page', () => {
         },
       ]),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1226,7 +1226,7 @@ describe('Consumer page', () => {
         },
       ]);
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1278,7 +1278,7 @@ describe('Consumer page', () => {
         },
       ]);
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1303,7 +1303,7 @@ describe('Consumer page', () => {
       },
     ]);
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1317,7 +1317,7 @@ describe('Consumer page', () => {
       new Error('request failed'),
     );
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1352,7 +1352,7 @@ describe('Consumer page', () => {
       failures: [{ index: 1, name: 'cg-fail', message: 'broker rejected group' }],
     });
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
         id: 4,
@@ -1368,7 +1368,16 @@ describe('Consumer page', () => {
     ]);
     renderWithProviders(<ConsumerPage />, '/instance/instance-proxy-1/consumer');
 
-    await screen.findByText(/共 0 个 Group/);
+    // NOT `findByText(/共 0 个 Group/)`: the header subtitle carries that text from the very
+    // first render (totalGroups starts at 0), so it resolves before the instance list lands.
+    // Uploading that early races `handleImportFile`'s `selectedInstanceId` guard, which then
+    // bails with 请先选择实例 and the import modal never opens. The instance-scoped fetch only
+    // fires once the selection resolved, so observing the call is the race-free readiness mark.
+    await waitFor(() =>
+      expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+      ),
+    );
     const csv = [
       '"Name","Subscription Mode","Consume Type","Retry Max Times","Subscription Data Type","Delivery Order Type"',
       '"cg-ok","Push","CLUSTERING","16","NORMAL",""',
@@ -1439,7 +1448,7 @@ describe('Consumer page', () => {
   });
 
   it('auto-refreshes the selected group while the detail modal is open', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1472,7 +1481,7 @@ describe('Consumer page', () => {
       retryQueueNums: 2,
       retryMaxTimes: 8,
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1525,7 +1534,7 @@ describe('Consumer page', () => {
       consumeMessageOrderly: true,
       consumeBroadcastEnable: false,
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1557,7 +1566,7 @@ describe('Consumer page', () => {
   });
 
   it('renders an unknown (-1) lag as unavailable in the table and the lag detail', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
       groupPage([
         { ...group, name: 'unknown-lag-cg', totalLag: -1 },
@@ -1577,7 +1586,7 @@ describe('Consumer page', () => {
   });
 
   it('sorts groups with an unknown lag after known backlogs in lag order', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
       groupPage([
         { ...group, name: 'unknown-lag-cg', totalLag: -1 },
@@ -1606,7 +1615,7 @@ describe('Consumer page', () => {
   });
 
   it('sorts groups with unavailable connections after known client counts', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
       groupPage([
         { ...group, name: 'unknown-conn-cg', onlineInstances: -1 },
@@ -1656,7 +1665,7 @@ describe('Consumer page', () => {
     vi.mocked(consumerService.getConsumerGroupSettings)
       .mockImplementationOnce(() => firstSettings.promise)
       .mockImplementationOnce(() => secondSettings.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
 
     const firstRow = await screen.findByRole('row', { name: /remote-cg/ });
diff --git a/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx b/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
index a2c82e56c..29887341c 100644
--- a/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
@@ -82,6 +82,11 @@ const renderPage = () =>
 
 describe('Consumer page diagnostic request ownership', () => {
   beforeEach(() => {
+    // The details modal re-diagnoses every 2s and the list refreshes on another interval.
+    // Faking only setInterval keeps those ticks off wall-clock time, so the requests in
+    // flight are exactly the three this test controls; with real timers the call-count
+    // assertion below fails whenever the machine is loaded enough for a tick to slip in.
+    vi.useFakeTimers({ toFake: ['setInterval'] });
     Object.defineProperty(window, 'matchMedia', {
       writable: true,
       value: vi.fn().mockImplementation((query: string) => ({
@@ -121,6 +126,7 @@ describe('Consumer page diagnostic request ownership', () => {
 
   afterEach(() => {
     cleanup();
+    vi.useRealTimers();
     vi.resetAllMocks();
   });
 
@@ -136,7 +142,7 @@ describe('Consumer page diagnostic request ownership', () => {
       .mockReturnValueOnce(second.promise)
       .mockReturnValueOnce(third.promise);
 
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
 
     const row = await screen.findByRole('row', { name: /remote-cg/ });
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 5025b85ba..791a6c799 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -189,7 +189,7 @@ describe('DLQ page', () => {
         page: 1,
         size: 20,
       });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -232,7 +232,7 @@ describe('DLQ page', () => {
     vi.mocked(messageService.listDLQGroups).mockResolvedValue(
       pageOf([dlqGroup, { ...secondDlqGroup, lastEnqueueTime: null }]),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -246,7 +246,7 @@ describe('DLQ page', () => {
   });
 
   it('opens a message detail drawer with the selected group metadata', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -299,7 +299,7 @@ describe('DLQ page', () => {
           resolveResend = resolve;
         }),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const firstGroupRow = (await screen.findByText('cg-order')).closest('tr');
@@ -375,7 +375,7 @@ describe('DLQ page', () => {
       page: 1,
       size: 20,
     } satisfies DLQMessagePage);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -412,7 +412,7 @@ describe('DLQ page', () => {
       page: 1,
       size: 20,
     } satisfies DLQMessagePage);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -431,7 +431,7 @@ describe('DLQ page', () => {
       }),
       meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -457,7 +457,7 @@ describe('DLQ page', () => {
       }),
       meta: { truncated: true, failedQueueCount: 2, limit: 100 },
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await screen.findByText('cg-order');
@@ -472,7 +472,7 @@ describe('DLQ page', () => {
 
   it('exports summaries for the selected groups in one CSV file', async () => {
     vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup, secondDlqGroup]));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const batchExport = screen.getByRole('button', { name: /批量导出/ });
@@ -505,7 +505,7 @@ describe('DLQ page', () => {
         },
       ]),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const row = (await screen.findByText('%DLQ%formula')).closest('tr');
@@ -521,7 +521,7 @@ describe('DLQ page', () => {
     vi.mocked(messageService.listDLQGroups)
       .mockResolvedValueOnce(pageOf([dlqGroup]))
       .mockResolvedValueOnce(pageOf([{ ...dlqGroup, messageCount: 0 }]));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -548,7 +548,7 @@ describe('DLQ page', () => {
     vi.mocked(messageService.resendDLQ).mockRejectedValue(
       new Error('DLQ provider is not configured'),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -569,7 +569,7 @@ describe('DLQ page', () => {
           resolveResend = resolve;
         }),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const row = (await screen.findByText('cg-order')).closest('tr');
@@ -595,7 +595,7 @@ describe('DLQ page', () => {
       scanIncomplete: true,
       failedQueueCount: 1,
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -620,7 +620,7 @@ describe('DLQ page', () => {
             resolveSecondInstance = resolve;
           }),
       );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -656,7 +656,7 @@ describe('DLQ page', () => {
     vi.mocked(messageService.listDLQGroups)
       .mockResolvedValueOnce(pageOf([dlqGroup]))
       .mockResolvedValueOnce(pageOf([secondDlqGroup]));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     const firstRow = (await screen.findByText('cg-order')).closest('tr');
@@ -694,7 +694,7 @@ describe('DLQ page', () => {
           }),
       )
       .mockResolvedValueOnce(pageOf([secondDlqGroup]));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<DLQPage />);
 
     await user.click(screen.getAllByRole('combobox')[0]);
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 065b3ac90..5be2b341f 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -130,7 +130,7 @@ describe('InstancePage', () => {
   });
 
   it('loads server-filtered results when the search or type changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
 
     expect(await screen.findByText('production-proxy')).toBeInTheDocument();
@@ -248,7 +248,7 @@ describe('InstancePage', () => {
   });
 
   it('ignores duplicate create submissions while the first request is pending', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.createInstance).mockImplementation(() => new Promise(() => {}));
     renderPage();
 
@@ -272,7 +272,7 @@ describe('InstancePage', () => {
   });
 
   it('reloads the current filters after creating an instance', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.createInstance).mockResolvedValue(instance(9, 'new-proxy'));
     renderPage();
 
@@ -309,7 +309,7 @@ describe('InstancePage', () => {
   });
 
   it('creates an Apache instance with an explicit Proxy Local deployment type', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.createInstance).mockResolvedValue(
       instance(3, 'local-proxy', 'PROXY_LOCAL'),
     );
@@ -339,7 +339,7 @@ describe('InstancePage', () => {
   });
 
   it('updates instance type and endpoint through the edit dialog', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.updateInstance).mockResolvedValue(
       instance(1, 'production-proxy', 'DIRECT'),
     );
@@ -373,7 +373,7 @@ describe('InstancePage', () => {
   });
 
   it('keeps the endpoint editable for Apache instances but read-only for cloud vendors', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       instance(1, 'production-proxy'),
       {
@@ -409,7 +409,7 @@ describe('InstancePage', () => {
   });
 
   it('submits a cloud instance whose stored endpoint is blank', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const blankEndpointCloud = {
       ...instance(2, 'aliyun-prod'),
       vendor: 'ALIYUN' as InstanceVendor,
@@ -441,7 +441,7 @@ describe('InstancePage', () => {
   });
 
   it('surfaces the server error reason when creating an instance fails', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.createInstance).mockRejectedValue({
       response: { data: { message: 'Instance name already exists: new-proxy' } },
     });
@@ -465,7 +465,7 @@ describe('InstancePage', () => {
   });
 
   it('surfaces the server error reason when updating an instance fails', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.updateInstance).mockRejectedValue({
       response: { data: { message: 'Instance endpoint is not reachable' } },
     });
@@ -486,7 +486,7 @@ describe('InstancePage', () => {
   });
 
   it('reloads the latest filters after a pending instance deletion completes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const pendingDelete = deferred<void>();
     vi.mocked(instanceService.deleteInstance).mockReturnValue(pendingDelete.promise);
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
@@ -518,7 +518,7 @@ describe('InstancePage', () => {
   });
 
   it('shows vendor tabs in the add instance modal and switches description', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
 
     expect(await screen.findByText('production-proxy')).toBeInTheDocument();
@@ -535,7 +535,7 @@ describe('InstancePage', () => {
   });
 
   it('imports every Aliyun instance of the credential via one-click import', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
       cloudCredentialPage([
         {
@@ -587,7 +587,7 @@ describe('InstancePage', () => {
   });
 
   it('imports every Tencent instance of the credential via one-click import', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
       cloudCredentialPage([
         {
@@ -643,7 +643,7 @@ describe('InstancePage', () => {
   });
 
   it('shows the total cloud import failure count when details are truncated', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
       cloudCredentialPage([
         {
@@ -688,7 +688,7 @@ describe('InstancePage', () => {
   });
 
   it('ignores a stale region response after the cloud credential changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const oldRegions = deferred<Array<{ regionId: string; regionName: string }>>();
     const latestRegions = deferred<Array<{ regionId: string; regionName: string }>>();
     vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
@@ -751,7 +751,7 @@ describe('InstancePage', () => {
   });
 
   it('ignores a stale instance response after the cloud region changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const oldInstances =
       deferred<
         Array<{ instanceId: string; instanceName: string; status: string; regionId: string }>
@@ -840,7 +840,7 @@ describe('InstancePage', () => {
   });
 
   it('clears a pending region load when the cloud vendor changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const pendingRegions = deferred<Array<{ regionId: string; regionName: string }>>();
     vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
       cloudCredentialPage([
@@ -898,7 +898,7 @@ describe('InstancePage', () => {
   });
 
   it('clears a pending instance load when the cloud vendor changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const pendingInstances =
       deferred<
         Array<{ instanceId: string; instanceName: string; status: string; regionId: string }>
@@ -974,7 +974,7 @@ describe('InstancePage', () => {
   });
 
   it('sorts and renders instances without remarks', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       instance(10, 'instance-without-remark', 'PROXY_CLUSTER', null),
       instance(11, 'instance-with-remark', 'PROXY_CLUSTER', 'production'),
@@ -1009,7 +1009,7 @@ describe('InstancePage', () => {
   });
 
   it('deletes selected instances through the toolbar batch delete button', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       instance(20, 'batch-a'),
       instance(21, 'batch-b'),
@@ -1044,7 +1044,7 @@ describe('InstancePage', () => {
   });
 
   it('clears selections hidden by a search result', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances)
       .mockResolvedValueOnce([instance(22, 'search-selected'), instance(23, 'search-visible')])
       .mockResolvedValueOnce([instance(23, 'search-visible')]);
@@ -1066,7 +1066,7 @@ describe('InstancePage', () => {
   });
 
   it('clears selections hidden by a type-filter result', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances)
       .mockResolvedValueOnce([
         instance(24, 'proxy-selected'),
@@ -1094,7 +1094,7 @@ describe('InstancePage', () => {
   });
 
   it('reconciles selections when a mutation refresh removes an instance', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(instanceService.listInstances)
       .mockResolvedValueOnce([instance(26, 'refresh-selected'), instance(27, 'refresh-trigger')])
       .mockResolvedValueOnce([instance(27, 'refresh-trigger')]);
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index dcbe3e155..b737ab01b 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -128,7 +128,7 @@ describe('Message page query history', () => {
   });
 
   it('requires the active query mode fields and trims submitted identifiers', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
     const queryButton = screen.getByRole('button', { name: /^search查询$/ });
 
@@ -183,7 +183,7 @@ describe('Message page query history', () => {
   });
 
   it('surfaces Topic loading failures and retries without changing instance', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     topicServiceMocks.listTopics
       .mockReset()
       .mockRejectedValueOnce(new Error('NameServer unavailable'))
@@ -200,7 +200,7 @@ describe('Message page query history', () => {
   });
 
   it('requires a topic even when a key or message ID is present', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
     const queryButton = screen.getByRole('button', { name: /^search查询$/ });
 
@@ -217,7 +217,7 @@ describe('Message page query history', () => {
   });
 
   it('does not report consume verification success without a backend API', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-CONSUME-VERIFY-001')]);
     renderWithProviders(<MessagePage />);
 
@@ -237,7 +237,7 @@ describe('Message page query history', () => {
   });
 
   it('sorts and renders messages without tags or keys', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     messageServiceMocks.queryMessages.mockResolvedValue([
       { ...createMessage('MID-NULL-FIELDS'), tag: null, key: null },
       { ...createMessage('MID-FULL-FIELDS'), tag: 'vip', key: 'order-001' },
@@ -266,7 +266,7 @@ describe('Message page query history', () => {
       selectInstance: vi.fn(),
       instanceOptions: [],
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
 
     await user.click(screen.getByText('按 Message ID'));
@@ -277,7 +277,7 @@ describe('Message page query history', () => {
   });
 
   it('shows the redelivery count on the message detail panel', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     messageServiceMocks.queryMessages.mockResolvedValue([
       { ...createMessage('MID-RETRY'), reconsumeTimes: 2 },
     ]);
@@ -334,7 +334,7 @@ describe('Message page query history', () => {
       instanceOptions: [{ value: 1, label: 'Instance A' }],
     });
     topicServiceMocks.listTopics.mockRejectedValue(new Error('topic lookup failed'));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
 
     await waitFor(() => expect(topicServiceMocks.listTopics).toHaveBeenCalledTimes(1));
@@ -347,7 +347,7 @@ describe('Message page query history', () => {
     messageServiceMocks.queryMessages.mockResolvedValue(
       Array.from({ length: 60 }, (_, index) => createMessage(`m-${index}`)),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
 
     await user.click(lastElement(screen.getAllByRole('combobox')));
@@ -382,7 +382,7 @@ describe('Message page query history', () => {
   });
 
   it('shows the storage location on the message detail panel', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-LOCATION')]);
     renderWithProviders(<MessagePage />);
 
@@ -441,7 +441,7 @@ describe('Message page query history', () => {
         },
       ],
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<MessagePage />);
 
     await user.click(screen.getByText('Query by Message ID'));
@@ -463,7 +463,7 @@ describe('Message page query history', () => {
   });
 
   it('renders placeholders on the detail panel when the storage location is unknown', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     messageServiceMocks.queryMessages.mockResolvedValue([
       { ...createMessage('MID-NO-LOCATION'), brokerName: null, queueId: null, queueOffset: null },
     ]);
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 4b3c8ea15..25edbcb94 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -175,7 +175,7 @@ describe('MessagePage async request ownership', () => {
   it('does not restore query results after the user resets an in-flight query', async () => {
     const query = createDeferred<MessageRecord[]>();
     serviceMocks.queryMessages.mockReturnValue(query.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -198,7 +198,7 @@ describe('MessagePage async request ownership', () => {
       size: 50,
       resultMayBeTruncated: true,
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -226,7 +226,7 @@ describe('MessagePage async request ownership', () => {
         resultMayBeTruncated: true,
       })
       .mockReturnValueOnce(lateQuery.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -264,7 +264,7 @@ describe('MessagePage async request ownership', () => {
         { value: 2, label: 'Instance B' },
       ],
     }));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const view = renderPage();
     await selectTopic(user);
 
@@ -288,7 +288,7 @@ describe('MessagePage async request ownership', () => {
     serviceMocks.queryMessages.mockRejectedValue(
       new Error('Message query provider is not configured'),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -302,7 +302,7 @@ describe('MessagePage async request ownership', () => {
     serviceMocks.getMessageTrace.mockRejectedValue(
       new Error('Message query provider is not configured'),
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -319,7 +319,7 @@ describe('MessagePage async request ownership', () => {
   it('loads a message trace lazily and reuses it for the same message', async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
     serviceMocks.getMessageTrace.mockResolvedValue(createTrace('cached-trace'));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -346,7 +346,7 @@ describe('MessagePage async request ownership', () => {
 
   it('requiresGroupAndClientBeforeDirectConsumeTest', async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -373,7 +373,7 @@ describe('MessagePage async request ownership', () => {
   it('queries trace by key with a custom trace topic from the trace tab', async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
     serviceMocks.getMessageTraceByKey.mockResolvedValue(createTrace('key-trace'));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -405,7 +405,7 @@ describe('MessagePage async request ownership', () => {
     const pendingTrace = createDeferred<TraceRecord>();
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
     serviceMocks.getMessageTrace.mockReturnValue(pendingTrace.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -472,7 +472,7 @@ describe('MessagePage async request ownership', () => {
         },
       ],
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -492,7 +492,7 @@ describe('MessagePage async request ownership', () => {
 
   it('remembers a custom trace topic per instance across page remounts', async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('remembered-message')]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const firstRender = renderPage();
     await selectTopic(user);
 
@@ -534,7 +534,7 @@ describe('MessagePage async request ownership', () => {
         { value: 2, label: 'Instance B' },
       ],
     }));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const view = renderPage();
     await selectTopic(user);
     await user.click(screen.getByRole('button', { name: /^search查询$/ }));
@@ -576,7 +576,7 @@ describe('MessagePage async request ownership', () => {
       size: 20,
     });
     serviceMocks.queryMessages.mockResolvedValue([createMessage('history-message')]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
 
     await user.click(screen.getByRole('button', { name: /服务端历史/ }));
@@ -602,7 +602,7 @@ describe('MessagePage async request ownership', () => {
     serviceMocks.queryMessages
       .mockReturnValueOnce(firstQuery.promise)
       .mockReturnValueOnce(secondQuery.promise);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -637,7 +637,7 @@ describe('MessagePage async request ownership', () => {
       serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
       serviceMocks.getMessageTrace.mockReturnValue(trace.promise);
       const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
-      const user = userEvent.setup();
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
       renderPage();
       await selectTopic(user);
 
@@ -675,7 +675,7 @@ describe('MessagePage async request ownership', () => {
     serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
       msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
@@ -715,7 +715,7 @@ describe('MessagePage async request ownership', () => {
     serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
       msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
     await selectTopic(user);
 
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index f316dcfd9..d426ac9a4 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -253,7 +253,7 @@ describe('TopicPage', () => {
         size: 20,
       });
     topicServiceMocks.createTopic.mockResolvedValue(createdTopic);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     expect(await screen.findByText('共 1 个 Topic')).toBeInTheDocument();
@@ -285,7 +285,7 @@ describe('TopicPage', () => {
       perm: 'RO',
       remark: 'updated remark',
     });
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -319,7 +319,7 @@ describe('TopicPage', () => {
   });
 
   it('invalidates an open Topic edit when the selected instance changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const topicA = { ...buildTopics(1)[0], name: 'topic-a', instanceId: 'instance-a' };
     const topicB = { ...buildTopics(1)[0], name: 'topic-b', instanceId: 'instance-b' };
     instanceServiceMocks.listInstances.mockResolvedValue([
@@ -355,7 +355,7 @@ describe('TopicPage', () => {
       { ...selectedInstance, vendor: 'ALIYUN' },
     ]);
     topicServiceMocks.updateTopic.mockResolvedValue(buildTopics(1)[0]);
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -383,7 +383,7 @@ describe('TopicPage', () => {
   });
 
   it('sends a normal test message from an Aliyun cloud topic', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       { ...selectedInstance, type: 'CLOUD', vendor: 'ALIYUN' },
     ]);
@@ -409,7 +409,7 @@ describe('TopicPage', () => {
   });
 
   it('opens a clean create dialog after a cancelled edit', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -430,7 +430,7 @@ describe('TopicPage', () => {
 
   it('ignores duplicate Topic creates while the first request is pending', async () => {
     topicServiceMocks.createTopic.mockImplementation(() => new Promise(() => {}));
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -447,7 +447,7 @@ describe('TopicPage', () => {
   });
 
   it('downloads all topics matching the current filters when exporting', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
     let exportedBlob: Blob | undefined;
     vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
@@ -502,7 +502,7 @@ describe('TopicPage', () => {
   });
 
   it('keeps matching rows when the search term has leading or trailing spaces', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     // The server query uses the trimmed term, so it returns the matching topic; the
     // client-side row filter must not re-filter with the padded raw input.
     mockTopicsList([{ ...buildTopics(1)[0], name: 'orders-topic' }]);
@@ -521,7 +521,7 @@ describe('TopicPage', () => {
   });
 
   it('keeps the current table page after opening and closing topic details', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
         id: 6,
@@ -567,7 +567,7 @@ describe('TopicPage', () => {
   });
 
   it('clamps back to a valid page when the current page becomes empty after a delete', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
         id: 6,
@@ -624,7 +624,7 @@ describe('TopicPage', () => {
   });
 
   it('reloads the server page after deleting one topic', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const topic = buildTopics(1)[0];
     let call = 0;
     topicServiceMocks.listTopicsPage.mockImplementation(async () => {
@@ -661,7 +661,7 @@ describe('TopicPage', () => {
   });
 
   it('keeps the selected instance when rebuilding a topic without a broker route', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const topic = { ...buildTopics(1)[0], instanceId: 'instance-a' };
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
@@ -691,7 +691,7 @@ describe('TopicPage', () => {
   });
 
   it('renders topic route health diagnostics in the detail modal', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const routes: BrokerRoute[] = [
       {
         brokerName: 'broker-a',
@@ -746,7 +746,7 @@ describe('TopicPage', () => {
   });
 
   it('keeps failed topics selected after a partially successful batch deletion', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const remainingTopics = [buildTopics(3)[1]];
     topicServiceMocks.listTopicsPage
       .mockResolvedValueOnce({
@@ -789,7 +789,7 @@ describe('TopicPage', () => {
   });
 
   it('clears selected topics when the search scope changes', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders();
 
     const row = await screen.findByRole('row', { name: /topic-01/ });
@@ -803,7 +803,7 @@ describe('TopicPage', () => {
   });
 
   it('moves back from an emptied last topic page after batch deletion', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const firstPage = buildTopics(20);
     const secondPage = [buildTopics(21)[20]];
     let deletedLastPage = false;
@@ -898,12 +898,21 @@ describe('TopicPage', () => {
   });
 
   it('imports valid topic CSV rows through the backend batch service with the selected instance', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([]);
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
     renderWithProviders('/instance/instance-proxy-1/topic');
 
-    await screen.findByText(/共 0 个 Topic/);
+    // NOT `findByText(/共 0 个 Topic/)`: the header subtitle carries that text from the very
+    // first render (totalTopics starts at 0), so it resolves before the instance list lands and
+    // the upload races `handleImportFile`'s `selectedInstanceId` guard, which bails with
+    // 请先选择实例 and the import modal never opens. The instance-scoped page fetch only fires
+    // once the selection resolved, so observing the call is the race-free readiness mark.
+    await waitFor(() =>
+      expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+      ),
+    );
     const csv = [
       '"Name","Namespace","Type","Cluster ID","Write Queues","Read Queues","Permission","Remark"',
       '"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
@@ -931,7 +940,7 @@ describe('TopicPage', () => {
   });
 
   it('reloads the paginated inventory after importing topics', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     const imported: Topic = {
       ...buildTopics(1)[0],
       name: 'imported-topic',
@@ -944,7 +953,12 @@ describe('TopicPage', () => {
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
     renderWithProviders('/instance/instance-proxy-1/topic');
 
-    await screen.findByText(/共 0 个 Topic/);
+    // Race-free readiness mark — see the comment in the batch-import test above.
+    await waitFor(() =>
+      expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+      ),
+    );
     const csv = [
       '"Name","Namespace","Type","Cluster ID","Write Queues","Read Queues","Permission","Remark"',
       '"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
@@ -960,7 +974,7 @@ describe('TopicPage', () => {
   });
 
   it('does not call importTopics when imported topic CSV is invalid or duplicated', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
     mockTopicsList([{ ...buildTopics(1)[0], instanceId: 'instance-proxy-1' }]);
     renderWithProviders('/instance/instance-proxy-1/topic');
@@ -982,7 +996,7 @@ describe('TopicPage', () => {
   });
 
   it('imports valid topic rows while skipping duplicate rows', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([]);
     instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
     topicServiceMocks.importTopics.mockResolvedValue({
@@ -1044,7 +1058,7 @@ describe('TopicPage', () => {
   });
 
   it('previews the send payload and submits the normalized properties', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([buildTopics(1)[0]]);
     renderWithProviders();
 
@@ -1083,7 +1097,7 @@ describe('TopicPage', () => {
   });
 
   it('blocks duplicate form properties in the send payload preflight', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([buildTopics(1)[0]]);
     renderWithProviders();
 
@@ -1114,7 +1128,7 @@ describe('TopicPage', () => {
   });
 
   it('renders unavailable Topic consumer metrics distinctly from zero', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([buildTopics(1)[0]]);
     topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
       items: [
@@ -1145,7 +1159,7 @@ describe('TopicPage', () => {
   });
 
   it('renders subscription group names as links in the topic detail modal', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([buildTopics(1)[0]]);
     topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
       items: [
@@ -1170,7 +1184,7 @@ describe('TopicPage', () => {
   });
 
   it('keeps a reopened sync modal owned by its newest route check', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     let resolveFirst!: (routes: BrokerRoute[]) => void;
     let resolveSecond!: (routes: BrokerRoute[]) => void;
     const firstCheck = new Promise<BrokerRoute[]>((resolve) => {
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index 99ab398df..2dab3893c 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -67,7 +67,7 @@ describe('NotificationDeliveriesPage', () => {
   });
 
   it('retries a failed delivery from the list and refreshes its status', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     render(
       <App>
         <LangProvider>
@@ -145,7 +145,7 @@ describe('NotificationDeliveriesPage', () => {
             size: 20,
           },
     );
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     render(
       <App>
         <LangProvider>
@@ -171,7 +171,7 @@ describe('NotificationDeliveriesPage', () => {
   });
 
   it('surfaces a failed instance-list load with a retry instead of an empty filter', async () => {
-    const user = userEvent.setup();
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
     vi.mocked(listInstances)
       .mockRejectedValueOnce(new Error('the instance service is down'))
       .mockResolvedValueOnce([]);
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
index a64e7c036..02fa8a0ef 100644
--- a/web/src/test/setup.ts
+++ b/web/src/test/setup.ts
@@ -1,6 +1,7 @@
 /// <reference types="vitest/globals" />
 import '@testing-library/jest-dom/vitest';
 import { configure } from '@testing-library/react';
+import { message, notification } from 'antd';
 
 // findBy*/waitFor default to 1s, which antd's async rendering exceeds once the whole
 // suite runs in parallel.
@@ -10,3 +11,12 @@ configure({ asyncUtilTimeout: 5000 });
 beforeEach(() => {
   localStorage.clear();
 });
+
+// The static `message`/`notification` APIs render into body-level holders that RTL's cleanup
+// does not own: their notices (3s duration plus a leave animation) survive into later tests,
+// and their rc-notification timers fire mid-test on an orphaned React root — the source of
+// cross-test DOM pollution and "window is not defined" teardown errors under parallel load.
+afterEach(() => {
+  message.destroy();
+  notification.destroy();
+});