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

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 1f893158b329bd1d45a118eb28a5577ed2a9dc24
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 18:39:53 2026 +0800

    feat(ai): capture-replay for the triage list blocks + a shared replay tag
    
    The four AI triage blocks (traces / zipkin-traces / logs / browser-errors) 
now
    capture their list server-side (frozen-always) and replay it on reload with 
zero
    re-query — matching the five map blocks. The tools fetch the exact route 
response
    and ship it as spec.replayData; the composables render it locally (never 
seeding
    the vue-query cache, which would contaminate live views); the views + 
blocks gate
    every query in replay (main list, row-detail, facets, 
landing/instance/endpoint
    aux feeds) so a reloaded block fires no OAP request on mount or interaction.
    
    Capture shape per the trace-query flavor and the operator's ask:
    - native v2 (queryTraces) + Zipkin carry spans inline → freeze 30.
    - native v1 (queryBasicTraces) rows carry no spans → freeze 10 and hydrate 
each
      with queryTrace(traceId) so the v1 waterfall replays offline too.
    - logs / browser-errors → freeze up to 100 rows.
    The captured window is the chat's global range (derived, like the maps); the
    vestigial per-tool windowMinutes arg is dropped. Logs/browser query at 
SECOND
    step (format matched to step). fetch_pod_logs default look-back → 2 min.
    
    A shared ChatCapturedTag (new 'replay' icon + "captured <when>") replaces 
the
    per-block inline caption across ALL captured blocks incl. pod logs — every
    AI block is a point-in-time snapshot the agent captured, so it always reads 
as
    replay, icon shown from first render.
    
    Review fixes folded in: logs + browser views were missing the aux-query 
replay
    gate (fired 3 live requests on mount) — now gated like the traces view; the 
logs
    pager keeps its count hint in replay. The list fetchers are reused from
    http/query (load-bearing rule holds: client/graphqlPost is the only OAP 
talker).
    
    Validated live: traces froze 30 v2 rows, logs 100, both reachable.
---
 apps/bff/src/ai/resources/tools/visualization.yaml |  16 +--
 apps/bff/src/ai/skill/kubernetes/tools.ts          |   2 +-
 apps/bff/src/ai/skill/visualization/tools.ts       | 120 ++++++++++++++++++---
 apps/bff/src/ai/types.ts                           |  13 +++
 apps/bff/src/client/zipkin.ts                      |   7 +-
 apps/bff/src/http/query/trace.ts                   |   8 ++
 apps/ui/src/ai/ChatBrowserErrorsBlock.vue          |   9 +-
 apps/ui/src/ai/ChatCapturedTag.vue                 |  52 +++++++++
 apps/ui/src/ai/ChatDeploymentBlock.vue             |  14 +--
 apps/ui/src/ai/ChatEndpointDependencyBlock.vue     |  14 +--
 apps/ui/src/ai/ChatFigureBlock.vue                 |  17 +--
 apps/ui/src/ai/ChatHierarchyBlock.vue              |  14 +--
 apps/ui/src/ai/ChatInstanceTopologyBlock.vue       |  14 +--
 apps/ui/src/ai/ChatLogsBlock.vue                   |   9 +-
 apps/ui/src/ai/ChatPodLogsBlock.vue                |   4 +-
 apps/ui/src/ai/ChatProfilingBlock.vue              |  12 +--
 apps/ui/src/ai/ChatTopologyBlock.vue               |  16 +--
 apps/ui/src/ai/ChatTracesBlock.vue                 |   9 +-
 apps/ui/src/ai/ChatTranscript.vue                  |  10 +-
 apps/ui/src/ai/ChatZipkinTracesBlock.vue           |   9 +-
 apps/ui/src/ai/types.ts                            |  18 +++-
 apps/ui/src/ai/useAiConversations.ts               |  10 +-
 apps/ui/src/components/icons/Icon.vue              |   9 +-
 apps/ui/src/i18n/locales/en.json                   |   3 +
 .../browser-errors/LayerBrowserErrorsView.vue      |  25 +++--
 .../layer/browser-errors/useLayerBrowserErrors.ts  |  28 +++--
 apps/ui/src/layer/logs/LayerLogsView.vue           |  31 +++++-
 apps/ui/src/layer/logs/useLayerLogs.ts             |  32 +++++-
 apps/ui/src/layer/traces/LayerTracesView.vue       |  27 ++++-
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue |  24 ++++-
 apps/ui/src/layer/traces/useLayerTraces.ts         |  28 ++++-
 apps/ui/src/layer/traces/useZipkinTraces.ts        |  24 +++--
 32 files changed, 449 insertions(+), 179 deletions(-)

diff --git a/apps/bff/src/ai/resources/tools/visualization.yaml 
b/apps/bff/src/ai/resources/tools/visualization.yaml
index 5390d61..375c62a 100644
--- a/apps/bff/src/ai/resources/tools/visualization.yaml
+++ b/apps/bff/src/ai/resources/tools/visualization.yaml
@@ -159,7 +159,7 @@ show_endpoint_dependency:
 
 show_traces:
   description: >-
-    Mount the native distributed-tracing view inline for a service — the real 
trace LIST plus the span WATERFALL, which the operator browses (click a trace 
to open its spans). Use it to surface slow / erroring traces for a service so a 
human can inspect them; there is no tool to read individual span data yourself. 
Provide the layer and service name; optionally a look-back windowMinutes 
(default 30). ONLY for a layer whose traces run NATIVE (SkyWalking segments) — 
for a Zipkin-tracing lay [...]
+    Mount the native distributed-tracing view inline for a service — the real 
trace LIST plus the span WATERFALL, which the operator browses (click a trace 
to open its spans). Use it to surface slow / erroring traces for a service so a 
human can inspect them; there is no tool to read individual span data yourself. 
The captured list is frozen over the current chat time range. Provide the layer 
and service name. ONLY for a layer whose traces run NATIVE (SkyWalking 
segments) — for a Zipkin- [...]
   params:
     layer: >-
       OAP layer key, e.g. GENERAL
@@ -167,8 +167,6 @@ show_traces:
       service NAME (from list_services)
     title: >-
       optional heading for the block
-    windowMinutes: >-
-      look-back window in minutes (default 30)
 
 list_zipkin_services:
   description: >-
@@ -179,7 +177,7 @@ list_zipkin_services:
 
 show_zipkin_traces:
   description: >-
-    Mount the Zipkin distributed-tracing view inline — the real trace LIST + 
span WATERFALL for a ZIPKIN service, which the operator browses. `service` MUST 
be a Zipkin service name from list_zipkin_services (NOT the SkyWalking name — 
they differ). Use this for a layer whose traces run on Zipkin (Envoy ALS / 
rover) after matching the service. Provide the layer (for context) and the 
matched Zipkin service name; optionally a look-back windowMinutes (default 30).
+    Mount the Zipkin distributed-tracing view inline — the real trace LIST + 
span WATERFALL for a ZIPKIN service, which the operator browses. `service` MUST 
be a Zipkin service name from list_zipkin_services (NOT the SkyWalking name — 
they differ). Use this for a layer whose traces run on Zipkin (Envoy ALS / 
rover) after matching the service. The captured list is frozen over the current 
chat time range. Provide the layer (for context) and the matched Zipkin service 
name.
   params:
     layer: >-
       OAP layer key, e.g. MESH
@@ -187,12 +185,10 @@ show_zipkin_traces:
       ZIPKIN service name (from list_zipkin_services)
     title: >-
       optional heading for the block
-    windowMinutes: >-
-      look-back window in minutes (default 30)
 
 show_logs:
   description: >-
-    Mount the layer LOGS view inline for a service — the real log stream the 
operator browses (click a row for its detail). Use it to surface a service's 
logs for a human to read. This is the layer Logs tab (stored logs); it is NOT 
fetch_pod_logs (that is the Kubernetes on-demand live tail). Provide the layer 
and service name; optionally a look-back windowMinutes (default 30).
+    Mount the layer LOGS view inline for a service — the real log stream the 
operator browses (click a row for its detail). Use it to surface a service's 
logs for a human to read. This is the layer Logs tab (stored logs); it is NOT 
fetch_pod_logs (that is the Kubernetes on-demand live tail). The captured 
stream (up to 100 rows) is frozen over the current chat time range. Provide the 
layer and service name.
   params:
     layer: >-
       OAP layer key, e.g. GENERAL
@@ -200,12 +196,10 @@ show_logs:
       service NAME (from list_services)
     title: >-
       optional heading for the block
-    windowMinutes: >-
-      look-back window in minutes (default 30)
 
 show_browser_logs:
   description: >-
-    Mount the browser-monitoring ERROR list inline for a browser app — the 
client-side JS error stream the operator browses (click a row for its stack 
trace). Use it for a BROWSER-family layer's app to surface front-end errors for 
a human. Provide the layer and service (browser-app) name; optionally a 
look-back windowMinutes (default 30).
+    Mount the browser-monitoring ERROR list inline for a browser app — the 
client-side JS error stream the operator browses (click a row for its stack 
trace). Use it for a BROWSER-family layer's app to surface front-end errors for 
a human. The captured list (up to 100 rows) is frozen over the current chat 
time range. Provide the layer and service (browser-app) name.
   params:
     layer: >-
       OAP browser-layer key
@@ -213,5 +207,3 @@ show_browser_logs:
       browser-app service NAME (from list_services)
     title: >-
       optional heading for the block
-    windowMinutes: >-
-      look-back window in minutes (default 30)
diff --git a/apps/bff/src/ai/skill/kubernetes/tools.ts 
b/apps/bff/src/ai/skill/kubernetes/tools.ts
index 0e00553..6afe473 100644
--- a/apps/bff/src/ai/skill/kubernetes/tools.ts
+++ b/apps/bff/src/ai/skill/kubernetes/tools.ts
@@ -45,7 +45,7 @@ import { graphqlPost } from '../../../client/graphql.js';
 import { fmtSecond, getServerOffsetMinutes } from '../../../util/window.js';
 import { toolPrompt } from '../../resources/loader.js';
 
-const DEFAULT_WINDOW_SEC = 300; // 5m trailing look-back for the agent's read
+const DEFAULT_WINDOW_SEC = 120; // 2m trailing look-back for the agent's read
 const MAX_WINDOW_SEC = 30 * 60; // 30m — same ceiling as the Pod Logs tab
 const MODEL_LINE_CAP = 80; // lines returned to the model (operator sees the 
full fetched result)
 const MODEL_CHAR_CAP = 8_000;
diff --git a/apps/bff/src/ai/skill/visualization/tools.ts 
b/apps/bff/src/ai/skill/visualization/tools.ts
index 2fb3331..10eee0d 100644
--- a/apps/bff/src/ai/skill/visualization/tools.ts
+++ b/apps/bff/src/ai/skill/visualization/tools.ts
@@ -34,6 +34,8 @@ import type {
   DashboardWidgetResult,
   DashboardWidgetType,
   TopologyResponse,
+  TraceListResponse,
+  ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { AiRequestContext } from '../../context.js';
 import type { HierarchyGroup, SubPageKind, TopoPeer } from '../../types.js';
@@ -53,9 +55,30 @@ import { buildServiceTopology, emptyTopologyResponse } from 
'../../../logic/oap/
 import { buildDeployment } from '../../../logic/oap/deployment.js';
 import { buildInstanceTopology } from 
'../../../logic/oap/instance-topology.js';
 import { buildEndpointDependency } from 
'../../../logic/oap/endpoint-dependency.js';
-import { zipkinFetchServices } from '../../../client/zipkin.js';
+import { zipkinFetchServices, zipkinFetchTraces } from 
'../../../client/zipkin.js';
+// Reuse the exported list fetchers so a captured triage block freezes the 
EXACT
+// response the interactive route produces (no re-derived query, no drift). 
They
+// take a resolved serviceId + OAP-local window and reach OAP only through
+// client/graphqlPost — the load-bearing rule holds. They live in http/query 
only
+// because their trace core (detectTraceQueryApi / TraceListBody / 
fetchZipkinList)
+// is entangled there; a move to logic/oap would relocate that whole core.
+import { fetchNativeList, fetchNativeTraceSpans } from 
'../../../http/query/trace.js';
+import { fetchLogs } from '../../../http/query/log.js';
+import { fetchBrowserErrors } from '../../../http/query/browser-errors.js';
+import { getServerOffsetMinutes, fmtSecond } from '../../../util/window.js';
 import { toolPrompt } from '../../resources/loader.js';
 
+// Capture caps for the frozen triage lists. Native v2 (queryTraces) + Zipkin
+// carry spans inline so 30 replays cheaply; v1 (queryBasicTraces) needs a
+// per-trace span fetch, so cap to 10. Logs/browser freeze up to 100 rows.
+const TRACE_CAP = 30;
+const V1_TRACE_CAP = 10;
+const LIST_CAP = 100;
+// Derive the captured window (minutes) from the chat's global range — the same
+// value the map tools emit, so the frozen block's window matches what was 
read.
+const rangeWindowMinutes = (r: { startMs: number; endMs: number }): number =>
+  Math.max(1, Math.round((r.endMs - r.startMs) / 60_000));
+
 const ri = toolPrompt('visualization', '_render_input');
 const renderInput = z.object({
   title: z.string().describe(ri.p('title')),
@@ -716,19 +739,44 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
   // list→detail interaction.
   const tracesPrompt = toolPrompt('visualization', 'show_traces');
   const tracesTool = tool(
-    async ({ layer, service, title, windowMinutes }: { layer: string; service: 
string; title?: string; windowMinutes?: number }): Promise<string> => {
+    async ({ layer, service, title }: { layer: string; service: string; 
title?: string }): Promise<string> => {
       if (!ctx.hasVerb('traces:read')) return 'Permission denied: the current 
user lacks traces:read.';
       const cat = await catalog();
       const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => 
s.name === service);
       if (!row) return `Unknown service "${service}" in layer ${layer}. Use 
list_services first.`;
+      // Freeze the native list (frozen-always) so the block replays offline.
+      const windowMinutes = rangeWindowMinutes(ctx.range);
+      const offsetMinutes = await getServerOffsetMinutes(ctx.config, 
ctx.fetch);
+      const native = await fetchNativeList(
+        ctx.opts,
+        { service, serviceId: row.id, startMs: ctx.range.startMs, endMs: 
ctx.range.endMs, pageSize: TRACE_CAP },
+        layer.toUpperCase(),
+        false,
+        offsetMinutes,
+        TRACE_CAP,
+      );
+      // v2 rows carry inline spans (keep 30); v1 rows have none, so cap to 10 
and
+      // hydrate each with its spans so the waterfall replays offline.
+      if (native.api === 'queryBasicTraces') {
+        native.traces = native.traces.slice(0, V1_TRACE_CAP);
+        for (const t of native.traces) {
+          const tid = t.traceIds[0];
+          if (tid) t.spans = await fetchNativeTraceSpans(ctx.opts, tid);
+        }
+      } else {
+        native.traces = native.traces.slice(0, TRACE_CAP);
+      }
+      const replayData: TraceListResponse = { generatedAt: Date.now(), source: 
'native', native };
       ctx.emitTraces({
         title: title || `Traces — ${service}`,
         layer: layer.toUpperCase(),
         service,
         serviceId: row.id,
-        windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : 
undefined,
+        windowMinutes,
+        replayData,
       });
-      return `Mounted the native Traces view for ${service}. The operator can 
browse the trace list and open a span waterfall in the chat.`;
+      if (!native.reachable) return `Traces for ${service} are unavailable 
(${native.error ?? 'unreachable'}).`;
+      return `Captured ${native.traces.length} native trace(s) for ${service} 
(${native.api === 'queryBasicTraces' ? 'v1' : 'v2'}). The operator can browse 
the frozen list and open a span waterfall in the chat.`;
     },
     {
       name: 'show_traces',
@@ -737,7 +785,6 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         layer: z.string().describe(tracesPrompt.p('layer')),
         service: z.string().describe(tracesPrompt.p('service')),
         title: z.string().optional().describe(tracesPrompt.p('title')),
-        windowMinutes: 
z.number().optional().describe(tracesPrompt.p('windowMinutes')),
       }),
     },
   );
@@ -781,15 +828,31 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
   // focused on a ZIPKIN service name the model matched via 
list_zipkin_services.
   const zipkinTracesPrompt = toolPrompt('visualization', 'show_zipkin_traces');
   const zipkinTracesTool = tool(
-    async ({ layer, service, title, windowMinutes }: { layer: string; service: 
string; title?: string; windowMinutes?: number }): Promise<string> => {
+    async ({ layer, service, title }: { layer: string; service: string; 
title?: string }): Promise<string> => {
       if (!ctx.hasVerb('traces:read')) return 'Permission denied: the current 
user lacks traces:read.';
+      const oap = ctx.config.current.oap;
+      const zopts = { queryUrl: oap.zipkinUrl, timeoutMs: oap.timeoutMs, auth: 
oap.auth, fetch: ctx.fetch };
+      // Freeze the Zipkin list WITH spans so the waterfall replays offline.
+      let replayData: ZipkinTraceListResponse;
+      try {
+        const rows = await zipkinFetchTraces(
+          zopts,
+          { serviceName: service, endTs: ctx.range.endMs, lookback: 
ctx.range.endMs - ctx.range.startMs, limit: TRACE_CAP },
+          true,
+        );
+        replayData = { source: 'zipkin', traces: rows, reachable: true };
+      } catch (err) {
+        replayData = { source: 'zipkin', traces: [], reachable: false, error: 
err instanceof Error ? err.message : String(err) };
+      }
       ctx.emitZipkinTraces({
         title: title || `Zipkin traces — ${service}`,
         layer: layer.toUpperCase(),
         service,
-        windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : 
undefined,
+        windowMinutes: rangeWindowMinutes(ctx.range),
+        replayData,
       });
-      return `Mounted the Zipkin Traces view for ${service}. The operator can 
browse the trace list and open a span waterfall in the chat. (If no traces 
match, the service name may not be a Zipkin service — re-check with 
list_zipkin_services.)`;
+      if (!replayData.reachable) return `Could not reach Zipkin for ${service} 
(${replayData.error}).`;
+      return `Captured ${replayData.traces.length} Zipkin trace(s) for 
${service}. The operator can browse the frozen list and open a span waterfall 
in the chat. (If empty, the name may not be a Zipkin service — re-check with 
list_zipkin_services.)`;
     },
     {
       name: 'show_zipkin_traces',
@@ -798,7 +861,6 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         layer: z.string().describe(zipkinTracesPrompt.p('layer')),
         service: z.string().describe(zipkinTracesPrompt.p('service')),
         title: z.string().optional().describe(zipkinTracesPrompt.p('title')),
-        windowMinutes: 
z.number().optional().describe(zipkinTracesPrompt.p('windowMinutes')),
       }),
     },
   );
@@ -808,19 +870,33 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
   // from fetch_pod_logs (the k8s on-demand live tail); this is the layer Logs 
tab.
   const logsPrompt = toolPrompt('visualization', 'show_logs');
   const logsTool = tool(
-    async ({ layer, service, title, windowMinutes }: { layer: string; service: 
string; title?: string; windowMinutes?: number }): Promise<string> => {
+    async ({ layer, service, title }: { layer: string; service: string; 
title?: string }): Promise<string> => {
       if (!ctx.hasVerb('logs:read')) return 'Permission denied: the current 
user lacks logs:read.';
       const cat = await catalog();
       const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => 
s.name === service);
       if (!row) return `Unknown service "${service}" in layer ${layer}. Use 
list_services first.`;
+      const maxLogs = Math.min(LIST_CAP, 
ctx.config.current.performance.limits.maxPageSize.logs);
+      // Logs query at SECOND step — format the window in SECOND (ctx.window 
is the
+      // chat's MINUTE-step string; mixing step + format throws 
verifyDateTimeString).
+      const logOffset = await getServerOffsetMinutes(ctx.config, ctx.fetch);
+      const logWindow = { start: fmtSecond(ctx.range.startMs, logOffset), end: 
fmtSecond(ctx.range.endMs, logOffset) };
+      const replayData = await fetchLogs(
+        ctx.opts,
+        { serviceId: row.id },
+        logWindow,
+        { pageNum: 1, pageSize: maxLogs },
+        false,
+      );
       ctx.emitLogs({
         title: title || `Logs — ${service}`,
         layer: layer.toUpperCase(),
         service,
         serviceId: row.id,
-        windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : 
undefined,
+        windowMinutes: rangeWindowMinutes(ctx.range),
+        replayData,
       });
-      return `Mounted the Logs view for ${service}. The operator can browse 
the log stream and open a record's detail in the chat.`;
+      if (!replayData.reachable) return `Logs for ${service} are unavailable 
(${replayData.error ?? 'unreachable'}).`;
+      return `Captured ${replayData.logs.length} log row(s) for ${service}. 
The operator can browse the frozen stream and open a record's detail in the 
chat.`;
     },
     {
       name: 'show_logs',
@@ -829,7 +905,6 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         layer: z.string().describe(logsPrompt.p('layer')),
         service: z.string().describe(logsPrompt.p('service')),
         title: z.string().optional().describe(logsPrompt.p('title')),
-        windowMinutes: 
z.number().optional().describe(logsPrompt.p('windowMinutes')),
       }),
     },
   );
@@ -839,19 +914,31 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
   // error stream + its row→stack-trace detail (BROWSER-family layers only).
   const browserErrorsPrompt = toolPrompt('visualization', 'show_browser_logs');
   const browserErrorsTool = tool(
-    async ({ layer, service, title, windowMinutes }: { layer: string; service: 
string; title?: string; windowMinutes?: number }): Promise<string> => {
+    async ({ layer, service, title }: { layer: string; service: string; 
title?: string }): Promise<string> => {
       if (!ctx.hasVerb('browser-errors:read')) return 'Permission denied: the 
current user lacks browser-errors:read.';
       const cat = await catalog();
       const row = (cat.byLayer.get(layer.toUpperCase()) ?? []).find((s) => 
s.name === service);
       if (!row) return `Unknown service "${service}" in layer ${layer}. Use 
list_services first.`;
+      const maxBrowser = Math.min(LIST_CAP, 
ctx.config.current.performance.limits.maxPageSize.browserLogs);
+      const beOffset = await getServerOffsetMinutes(ctx.config, ctx.fetch);
+      const beWindow = { start: fmtSecond(ctx.range.startMs, beOffset), end: 
fmtSecond(ctx.range.endMs, beOffset) };
+      const replayData = await fetchBrowserErrors(
+        ctx.opts,
+        { serviceId: row.id },
+        beWindow,
+        { pageNum: 1, pageSize: maxBrowser },
+        false,
+      );
       ctx.emitBrowserErrors({
         title: title || `Browser errors — ${service}`,
         layer: layer.toUpperCase(),
         service,
         serviceId: row.id,
-        windowMinutes: windowMinutes && windowMinutes > 0 ? windowMinutes : 
undefined,
+        windowMinutes: rangeWindowMinutes(ctx.range),
+        replayData,
       });
-      return `Mounted the Browser errors view for ${service}. The operator can 
browse the client-side error list and open a stack trace in the chat.`;
+      if (!replayData.reachable) return `Browser errors for ${service} are 
unavailable (${replayData.error ?? 'unreachable'}).`;
+      return `Captured ${replayData.logs.length} browser error(s) for 
${service}. The operator can browse the frozen list and open a stack trace in 
the chat.`;
     },
     {
       name: 'show_browser_logs',
@@ -860,7 +947,6 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         layer: z.string().describe(browserErrorsPrompt.p('layer')),
         service: z.string().describe(browserErrorsPrompt.p('service')),
         title: z.string().optional().describe(browserErrorsPrompt.p('title')),
-        windowMinutes: 
z.number().optional().describe(browserErrorsPrompt.p('windowMinutes')),
       }),
     },
   );
diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts
index ff36791..428a61b 100644
--- a/apps/bff/src/ai/types.ts
+++ b/apps/bff/src/ai/types.ts
@@ -24,14 +24,18 @@
  */
 
 import type {
+  BrowserErrorsResponse,
   DashboardWidget,
   DashboardWidgetResult,
   DeploymentResponse,
   EndpointDependencyResponse,
   InstanceTopologyResponse,
+  LogsResponse,
   ProfileAnalyzationTree,
   ServiceHierarchyResponse,
   TopologyResponse,
+  TraceListResponse,
+  ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { ProfilingLogLine, ProfilingSummary, ProfilingType } from 
'../logic/oap/profiling.js';
 
@@ -271,6 +275,9 @@ export interface TracesSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  /** Captured native trace list (rows carry inline spans on v2). Replay 
renders
+   *  it frozen; v1 detail (no inline spans) is disabled offline. */
+  replayData?: TraceListResponse;
 }
 
 /** A mounted ZIPKIN TRACES view — the real Zipkin trace view embedded 
read-only,
@@ -283,6 +290,8 @@ export interface ZipkinTracesSpec {
   layer: string;
   service: string;
   windowMinutes?: number;
+  /** Captured Zipkin trace list WITH spans, so a reload replays the waterfall 
offline. */
+  replayData?: ZipkinTraceListResponse;
 }
 
 /** A mounted LOGS view — the real layer Logs view embedded read-only, focused 
on
@@ -293,6 +302,8 @@ export interface LogsSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  /** Captured log rows — self-contained (detail/facets are client-side). */
+  replayData?: LogsResponse;
 }
 
 /** A mounted BROWSER-ERRORS view — the real browser-monitoring error list
@@ -304,6 +315,8 @@ export interface BrowserErrorsSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  /** Captured error rows — self-contained (stack/detail are client-side). */
+  replayData?: BrowserErrorsResponse;
 }
 
 export type SseEvent =
diff --git a/apps/bff/src/client/zipkin.ts b/apps/bff/src/client/zipkin.ts
index 5740f12..1e2354a 100644
--- a/apps/bff/src/client/zipkin.ts
+++ b/apps/bff/src/client/zipkin.ts
@@ -140,6 +140,9 @@ export function summariseZipkinTrace(spans: ZipkinSpan[]): 
ZipkinTraceListRow {
 export async function zipkinFetchTraces(
   opts: ZipkinClientOpts,
   query: ZipkinTracesQuery,
+  /** Carry each trace's spans through on the row (for a frozen, 
offline-replayable
+   *  capture); the interactive list omits them and re-fetches on click. */
+  carrySpans = false,
 ): Promise<ZipkinTraceListRow[]> {
   const qs = new URLSearchParams();
   if (query.serviceName) qs.set('serviceName', query.serviceName);
@@ -155,7 +158,9 @@ export async function zipkinFetchTraces(
   qs.set('limit', String(query.limit ?? 20));
   const path = `/api/v2/traces?${qs.toString()}`;
   const arr = await zipkinFetch<ZipkinSpan[][]>(opts, path);
-  return arr.map(summariseZipkinTrace).filter((r) => r.traceId);
+  return arr
+    .map((spans) => (carrySpans ? { ...summariseZipkinTrace(spans), spans } : 
summariseZipkinTrace(spans)))
+    .filter((r) => r.traceId);
 }
 
 /** Fetch a single trace by id. Returns the full span array
diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts
index e9e1ddd..c878122 100644
--- a/apps/bff/src/http/query/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -374,6 +374,14 @@ export async function fetchNativeList(
   }
 }
 
+/** Fetch one native trace's spans (queryTrace). Hydrates v1 (queryBasicTraces)
+ *  list rows — which carry no inline spans — so a captured list can replay the
+ *  waterfall offline (the AI trace-capture path). */
+export async function fetchNativeTraceSpans(opts: GraphqlOptions, traceId: 
string): Promise<NativeSpan[]> {
+  const env = await graphqlPost<{ trace: { spans: NativeSpan[] } | null 
}>(opts, QUERY_TRACE_DETAIL, { traceId });
+  return env.trace?.spans ?? [];
+}
+
 export async function fetchZipkinList(
   opts: GraphqlOptions,
   body: TraceListBody,
diff --git a/apps/ui/src/ai/ChatBrowserErrorsBlock.vue 
b/apps/ui/src/ai/ChatBrowserErrorsBlock.vue
index a65c8a1..e5ee192 100644
--- a/apps/ui/src/ai/ChatBrowserErrorsBlock.vue
+++ b/apps/ui/src/ai/ChatBrowserErrorsBlock.vue
@@ -21,21 +21,26 @@
 <script setup lang="ts">
 import { useI18n } from 'vue-i18n';
 import LayerBrowserErrorsView from 
'@/layer/browser-errors/LayerBrowserErrorsView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { BrowserErrorsSpec } from './types';
 
-defineProps<{ n: number; spec: BrowserErrorsSpec }>();
+defineProps<{ n: number; spec: BrowserErrorsSpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
 </script>
 
 <template>
   <div class="cbe">
-    <div class="cbe__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div>
+    <div class="cbe__cap">
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
+    </div>
     <div class="cbe__view">
       <LayerBrowserErrorsView
         :embedded="true"
         :layer-key="spec.layer.toLowerCase()"
         :focus-service="spec.service"
         :focus-window-minutes="spec.windowMinutes"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatCapturedTag.vue 
b/apps/ui/src/ai/ChatCapturedTag.vue
new file mode 100644
index 0000000..4602cf3
--- /dev/null
+++ b/apps/ui/src/ai/ChatCapturedTag.vue
@@ -0,0 +1,52 @@
+<!--
+  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.
+-->
+<!-- Shared "captured <when>" tag for every frozen/replayed chat block 
(figures,
+     maps, profiling, triage lists). The replay icon marks the block as a
+     point-in-time snapshot replayed from history, not live data. -->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+
+const props = defineProps<{ at?: number }>();
+const { t } = useI18n({ useScope: 'global' });
+const when = computed<string>(() =>
+  props.at
+    ? new Date(props.at).toLocaleString([], { month: 'short', day: 'numeric', 
hour: '2-digit', minute: '2-digit' })
+    : '',
+);
+</script>
+
+<template>
+  <span class="cct" :title="t('Captured snapshot — replayed from history, not 
live data')">
+    <span class="cct__sep">·</span>
+    <Icon name="replay" :size="11" />
+    <span>{{ when ? t('captured {when}', { when }) : t('captured') }}</span>
+  </span>
+</template>
+
+<style scoped>
+.cct {
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  color: var(--sw-fg-3, #6b6f7a);
+}
+.cct__sep {
+  margin-right: 1px;
+}
+</style>
diff --git a/apps/ui/src/ai/ChatDeploymentBlock.vue 
b/apps/ui/src/ai/ChatDeploymentBlock.vue
index e26bbc7..754212a 100644
--- a/apps/ui/src/ai/ChatDeploymentBlock.vue
+++ b/apps/ui/src/ai/ChatDeploymentBlock.vue
@@ -20,24 +20,19 @@
      bespoke graph: one renderer across the product, so the instance hexes, 
ring
      health, animated edges and node/edge detail are identical to the page. -->
 <script setup lang="ts">
-import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import LayerDeploymentView from '@/layer/service-map/LayerDeploymentView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { DeploymentSpec } from './types';
 
-const props = defineProps<{ n: number; spec: DeploymentSpec; capturedAt?: 
number }>();
+defineProps<{ n: number; spec: DeploymentSpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 </script>
 
 <template>
   <div class="cdp">
     <div class="cdp__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="cdp__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="cdp__view">
       <LayerDeploymentView
@@ -69,9 +64,6 @@ const captured = computed<string>(() =>
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.cdp__ts {
-  color: var(--sw-fg-3);
-}
 /* The embedded view is content-sized (its canvas carries a bounded pixel
    height), so the block just clips any overflow and keeps its own rounded 
edge. */
 .cdp__view {
diff --git a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue 
b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
index d37a0ba..854123d 100644
--- a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
+++ b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
@@ -21,24 +21,19 @@
      upstream/downstream chain; node expand + node/edge detail stay intact. No
      bespoke graph: one renderer across the product. -->
 <script setup lang="ts">
-import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import LayerEndpointDependencyView from 
'@/layer/endpoint-dependency/LayerEndpointDependencyView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { EndpointDependencySpec } from './types';
 
-const props = defineProps<{ n: number; spec: EndpointDependencySpec; 
capturedAt?: number }>();
+defineProps<{ n: number; spec: EndpointDependencySpec; capturedAt?: number 
}>();
 const { t } = useI18n({ useScope: 'global' });
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 </script>
 
 <template>
   <div class="ced">
     <div class="ced__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="ced__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="ced__view">
       <LayerEndpointDependencyView
@@ -71,9 +66,6 @@ const captured = computed<string>(() =>
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.ced__ts {
-  color: var(--sw-fg-3);
-}
 /* The embedded view is content-sized (its graph card carries a bounded pixel
    height); the block clips any overflow and keeps its own rounded edge. */
 .ced__view {
diff --git a/apps/ui/src/ai/ChatFigureBlock.vue 
b/apps/ui/src/ai/ChatFigureBlock.vue
index 12a404b..83e8ff3 100644
--- a/apps/ui/src/ai/ChatFigureBlock.vue
+++ b/apps/ui/src/ai/ChatFigureBlock.vue
@@ -20,6 +20,7 @@
 import { computed, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import ChatWidgetRenderer from './ChatWidgetRenderer.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { FigureBlock } from './types';
 
 const props = defineProps<{ block: FigureBlock }>();
@@ -34,20 +35,12 @@ const label = computed<string>(() => {
       : t('Figure {n}', { n: props.block.n });
   return props.block.title ? `${base} · ${props.block.title}` : base;
 });
-
-// Frozen point-in-time data — stamp when it was captured so a reloaded figure
-// isn't read as current.
-const captured = computed<string>(() =>
-  props.block.capturedAt
-    ? new Date(props.block.capturedAt).toLocaleString([], { month: 'short', 
day: 'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 </script>
 
 <template>
   <figure class="cfb">
     <figcaption class="cfb__label">
-      {{ label }}<span v-if="captured" class="cfb__ts"> · captured {{ captured 
}}</span>
+      {{ label }}<ChatCapturedTag :at="block.capturedAt" />
     </figcaption>
 
     <!-- single -->
@@ -99,12 +92,6 @@ const captured = computed<string>(() =>
   color: var(--sw-fg-3);
   margin-bottom: 8px;
 }
-.cfb__ts {
-  font-weight: var(--sw-fw-medium);
-  text-transform: none;
-  letter-spacing: normal;
-  color: var(--sw-fg-3);
-}
 .cfb__cap {
   font-size: var(--sw-fs-sm);
   font-weight: var(--sw-fw-medium);
diff --git a/apps/ui/src/ai/ChatHierarchyBlock.vue 
b/apps/ui/src/ai/ChatHierarchyBlock.vue
index 464394c..425130d 100644
--- a/apps/ui/src/ai/ChatHierarchyBlock.vue
+++ b/apps/ui/src/ai/ChatHierarchyBlock.vue
@@ -19,18 +19,13 @@
      standalone mode: no topology underneath, no dim/modal, the fan centred and
      auto-fit into its own viewBox. One renderer across the product. -->
 <script setup lang="ts">
-import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import ServiceHierarchyOverlay from 
'@/layer/service-map/ServiceHierarchyOverlay.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { HierarchySpec } from './types';
 
-const props = defineProps<{ n: number; spec: HierarchySpec; capturedAt?: 
number }>();
+defineProps<{ n: number; spec: HierarchySpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 
 // resolveNodePos is unused in standalone mode (the fan centres on the origin),
 // but the prop is required for the topology-overlay path.
@@ -42,7 +37,7 @@ function noNodePos(): null {
 <template>
   <div class="chb">
     <div class="chb__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="chb__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="chb__view">
       <ServiceHierarchyOverlay
@@ -75,9 +70,6 @@ function noNodePos(): null {
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.chb__ts {
-  color: var(--sw-fg-3);
-}
 /* Stage for the standalone Smartscape fan (position:relative so the overlay's
    absolute fill anchors here; the fan's viewBox scales to this box). */
 .chb__view {
diff --git a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue 
b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
index 2bc06bb..4ee5d87 100644
--- a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
+++ b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
@@ -21,24 +21,19 @@
      instances) with the calls between them; pan/zoom + node/edge detail 
intact.
      No bespoke graph: one renderer across the product. -->
 <script setup lang="ts">
-import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import LayerInstanceTopologyView from 
'@/layer/service-map/LayerInstanceTopologyView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { InstanceTopologySpec } from './types';
 
-const props = defineProps<{ n: number; spec: InstanceTopologySpec; 
capturedAt?: number }>();
+defineProps<{ n: number; spec: InstanceTopologySpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 </script>
 
 <template>
   <div class="cit">
     <div class="cit__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="cit__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="cit__view">
       <LayerInstanceTopologyView
@@ -71,9 +66,6 @@ const captured = computed<string>(() =>
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.cit__ts {
-  color: var(--sw-fg-3);
-}
 /* The embedded view fills its host (.imv is height:100%), so give it a bounded
    fixed stage; fit-to-screen + the zoom controls handle a wider graph. */
 .cit__view {
diff --git a/apps/ui/src/ai/ChatLogsBlock.vue b/apps/ui/src/ai/ChatLogsBlock.vue
index 7b444d6..6290f5f 100644
--- a/apps/ui/src/ai/ChatLogsBlock.vue
+++ b/apps/ui/src/ai/ChatLogsBlock.vue
@@ -20,21 +20,26 @@
 <script setup lang="ts">
 import { useI18n } from 'vue-i18n';
 import LayerLogsView from '@/layer/logs/LayerLogsView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { LogsSpec } from './types';
 
-defineProps<{ n: number; spec: LogsSpec }>();
+defineProps<{ n: number; spec: LogsSpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
 </script>
 
 <template>
   <div class="clg">
-    <div class="clg__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div>
+    <div class="clg__cap">
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
+    </div>
     <div class="clg__view">
       <LayerLogsView
         :embedded="true"
         :layer-key="spec.layer.toLowerCase()"
         :focus-service="spec.service"
         :focus-window-minutes="spec.windowMinutes"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatPodLogsBlock.vue 
b/apps/ui/src/ai/ChatPodLogsBlock.vue
index 802d36c..73c00e6 100644
--- a/apps/ui/src/ai/ChatPodLogsBlock.vue
+++ b/apps/ui/src/ai/ChatPodLogsBlock.vue
@@ -23,9 +23,10 @@
 import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import Icon from '@/components/icons/Icon.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { PodLogSpec } from './types';
 
-const props = defineProps<{ n: number; spec: PodLogSpec }>();
+const props = defineProps<{ n: number; spec: PodLogSpec; capturedAt?: number 
}>();
 const { t } = useI18n({ useScope: 'global' });
 
 const target = computed(() => [props.spec.service, 
props.spec.pod].filter(Boolean).join(' / '));
@@ -49,6 +50,7 @@ const hasFilter = computed(() => includeKw.value.length > 0 
|| excludeKw.value.l
       <span v-if="target" class="plog__target">{{ target }}</span>
       <span v-if="target" class="plog__sep">·</span>
       <span class="plog__container-name">{{ spec.container }}</span>
+      <ChatCapturedTag :at="capturedAt" />
     </div>
 
     <div v-if="hasFilter" class="plog__filter">
diff --git a/apps/ui/src/ai/ChatProfilingBlock.vue 
b/apps/ui/src/ai/ChatProfilingBlock.vue
index 3dce609..8a0090c 100644
--- a/apps/ui/src/ai/ChatProfilingBlock.vue
+++ b/apps/ui/src/ai/ChatProfilingBlock.vue
@@ -18,17 +18,12 @@
 import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { ProfilingResultSpec } from './types';
 
 const props = defineProps<{ n: number; spec: ProfilingResultSpec; capturedAt?: 
number }>();
 const { t } = useI18n({ useScope: 'global' });
 
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
-
 const hasData = computed<boolean>(() => props.spec.reachable && 
props.spec.trees.some((tr) => tr.elements.length));
 
 // Compact task facts — the flame carries no context of its own.
@@ -51,7 +46,7 @@ const facts = computed<string[]>(() => {
 <template>
   <div class="cpf">
     <div class="cpf__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="cpf__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="cpf__facts">
       <span class="cpf__type">{{ spec.profilingType }}</span>
@@ -82,9 +77,6 @@ const facts = computed<string[]>(() => {
   color: var(--sw-fg-2, #9aa0ac);
   border-bottom: 1px solid var(--sw-line, #2a2d36);
 }
-.cpf__ts {
-  color: var(--sw-fg-3, #6b6f7a);
-}
 .cpf__facts {
   display: flex;
   flex-wrap: wrap;
diff --git a/apps/ui/src/ai/ChatTopologyBlock.vue 
b/apps/ui/src/ai/ChatTopologyBlock.vue
index a679352..ab7f7fa 100644
--- a/apps/ui/src/ai/ChatTopologyBlock.vue
+++ b/apps/ui/src/ai/ChatTopologyBlock.vue
@@ -20,26 +20,19 @@
      one-hop map. No bespoke graph: one renderer across the product, so the hex
      nodes, edges, health colours and metrics are identical to the page. -->
 <script setup lang="ts">
-import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import LayerServiceMapView from '@/layer/service-map/LayerServiceMapView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { TopologySpec } from './types';
 
-const props = defineProps<{ n: number; spec: TopologySpec; capturedAt?: number 
}>();
+defineProps<{ n: number; spec: TopologySpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
-
-// Frozen point-in-time graph — document WHEN it was captured (see 
ChatFigureBlock).
-const captured = computed<string>(() =>
-  props.capturedAt
-    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
-    : '',
-);
 </script>
 
 <template>
   <div class="ctp">
     <div class="ctp__cap">
-      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="ctp__ts"> · captured {{ captured }}</span>
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
     </div>
     <div class="ctp__view">
       <LayerServiceMapView
@@ -74,9 +67,6 @@ const captured = computed<string>(() =>
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.ctp__ts {
-  color: var(--sw-fg-3);
-}
 /* The embedded map fills its host (height:100%), so give it a bounded stage.
    Taller than a dashboard cell so a focused one-hop graph reads at a usable
    size; the higher fit-scale cap + zoom controls handle the rest. */
diff --git a/apps/ui/src/ai/ChatTracesBlock.vue 
b/apps/ui/src/ai/ChatTracesBlock.vue
index a91a743..73c27b2 100644
--- a/apps/ui/src/ai/ChatTracesBlock.vue
+++ b/apps/ui/src/ai/ChatTracesBlock.vue
@@ -27,10 +27,11 @@ import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import LayerTracesView from '@/layer/traces/LayerTracesView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import { useLayers } from '@/shell/useLayers';
 import type { TracesSpec } from './types';
 
-const props = defineProps<{ n: number; spec: TracesSpec }>();
+const props = defineProps<{ n: number; spec: TracesSpec; capturedAt?: number 
}>();
 const { t } = useI18n({ useScope: 'global' });
 const router = useRouter();
 const { layers } = useLayers();
@@ -50,7 +51,9 @@ function openZipkinTab(): void {
 
 <template>
   <div class="ctr">
-    <div class="ctr__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div>
+    <div class="ctr__cap">
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
+    </div>
 
     <div v-if="!hasTraces" class="ctr__note">{{ t('This layer has no traces 
component.') }}</div>
     <div v-else-if="isZipkinOnly" class="ctr__note ctr__note--act">
@@ -63,6 +66,8 @@ function openZipkinTab(): void {
         :layer-key="spec.layer.toLowerCase()"
         :focus-service="spec.service"
         :focus-window-minutes="spec.windowMinutes"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatTranscript.vue 
b/apps/ui/src/ai/ChatTranscript.vue
index db7a26d..9ec511a 100644
--- a/apps/ui/src/ai/ChatTranscript.vue
+++ b/apps/ui/src/ai/ChatTranscript.vue
@@ -96,16 +96,16 @@ function fmtTime(at: number): string {
           <ChatSubPageBlock v-else-if="b.kind === 'subpage'" :n="b.n" 
:spec="b.spec" />
           <ChatProposalBlock v-else-if="b.kind === 'proposal'" :block="b" />
           <ChatProfilingBlock v-else-if="b.kind === 'profiling'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
-          <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" 
:spec="b.spec" />
+          <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
           <ChatHierarchyBlock v-else-if="b.kind === 'hierarchy'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
           <ChatTopologyBlock v-else-if="b.kind === 'topology'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
           <ChatDeploymentBlock v-else-if="b.kind === 'deployment'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
           <ChatInstanceTopologyBlock v-else-if="b.kind === 
'instance-topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
           <ChatEndpointDependencyBlock v-else-if="b.kind === 
'endpoint-dependency'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
-          <ChatTracesBlock v-else-if="b.kind === 'traces'" :n="b.n" 
:spec="b.spec" />
-          <ChatZipkinTracesBlock v-else-if="b.kind === 'zipkin-traces'" 
:n="b.n" :spec="b.spec" />
-          <ChatLogsBlock v-else-if="b.kind === 'logs'" :n="b.n" :spec="b.spec" 
/>
-          <ChatBrowserErrorsBlock v-else-if="b.kind === 'browser-errors'" 
:n="b.n" :spec="b.spec" />
+          <ChatTracesBlock v-else-if="b.kind === 'traces'" :n="b.n" 
:spec="b.spec" :captured-at="b.capturedAt" />
+          <ChatZipkinTracesBlock v-else-if="b.kind === 'zipkin-traces'" 
:n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
+          <ChatLogsBlock v-else-if="b.kind === 'logs'" :n="b.n" :spec="b.spec" 
:captured-at="b.capturedAt" />
+          <ChatBrowserErrorsBlock v-else-if="b.kind === 'browser-errors'" 
:n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
           <span v-else class="tx__tool" :class="b.status">
             <span class="tx__tool-dot" />{{ b.name }}
           </span>
diff --git a/apps/ui/src/ai/ChatZipkinTracesBlock.vue 
b/apps/ui/src/ai/ChatZipkinTracesBlock.vue
index 66ae0cd..7502476 100644
--- a/apps/ui/src/ai/ChatZipkinTracesBlock.vue
+++ b/apps/ui/src/ai/ChatZipkinTracesBlock.vue
@@ -23,21 +23,26 @@
 <script setup lang="ts">
 import { useI18n } from 'vue-i18n';
 import LayerZipkinTracesView from '@/layer/traces/LayerZipkinTracesView.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
 import type { ZipkinTracesSpec } from './types';
 
-defineProps<{ n: number; spec: ZipkinTracesSpec }>();
+defineProps<{ n: number; spec: ZipkinTracesSpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
 </script>
 
 <template>
   <div class="czt">
-    <div class="czt__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div>
+    <div class="czt__cap">
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag 
:at="capturedAt" />
+    </div>
     <div class="czt__view">
       <LayerZipkinTracesView
         :embedded="true"
         :layer-key="spec.layer.toLowerCase()"
         :focus-service="spec.service"
         :focus-window-minutes="spec.windowMinutes"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts
index 8f29a6d..def09cf 100644
--- a/apps/ui/src/ai/types.ts
+++ b/apps/ui/src/ai/types.ts
@@ -26,6 +26,10 @@ import type {
   ProfileAnalyzationTree,
   ServiceHierarchyResponse,
   TopologyResponse,
+  TraceListResponse,
+  ZipkinTraceListResponse,
+  LogsResponse,
+  BrowserErrorsResponse,
 } from '@skywalking-horizon-ui/api-client';
 
 export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid';
@@ -245,6 +249,7 @@ export interface TracesSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  replayData?: TraceListResponse;
 }
 
 // A mounted Zipkin Traces view (real feature view, embedded read-only) 
focused on
@@ -255,6 +260,7 @@ export interface ZipkinTracesSpec {
   layer: string;
   service: string;
   windowMinutes?: number;
+  replayData?: ZipkinTraceListResponse;
 }
 
 // A mounted layer Logs view (real feature view, embedded read-only) focused 
on a
@@ -265,6 +271,7 @@ export interface LogsSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  replayData?: LogsResponse;
 }
 
 // A mounted browser-monitoring error list (real feature view, embedded
@@ -275,6 +282,7 @@ export interface BrowserErrorsSpec {
   service: string;
   serviceId?: string;
   windowMinutes?: number;
+  replayData?: BrowserErrorsResponse;
 }
 
 export type SseEvent =
@@ -306,16 +314,16 @@ export type Block =
   | { kind: 'subpage'; n: number; spec: SubPageSpec }
   | { kind: 'proposal'; n: number; spec: ProposalSpec; status: ProposalStatus; 
taskId?: string; error?: string }
   | { kind: 'profiling'; n: number; spec: ProfilingResultSpec; capturedAt?: 
number }
-  | { kind: 'podlogs'; n: number; spec: PodLogSpec }
+  | { kind: 'podlogs'; n: number; spec: PodLogSpec; capturedAt?: number }
   | { kind: 'hierarchy'; n: number; spec: HierarchySpec; capturedAt?: number }
   | { kind: 'topology'; n: number; spec: TopologySpec; capturedAt?: number }
   | { kind: 'deployment'; n: number; spec: DeploymentSpec; capturedAt?: number 
}
   | { kind: 'instance-topology'; n: number; spec: InstanceTopologySpec; 
capturedAt?: number }
   | { kind: 'endpoint-dependency'; n: number; spec: EndpointDependencySpec; 
capturedAt?: number }
-  | { kind: 'traces'; n: number; spec: TracesSpec }
-  | { kind: 'zipkin-traces'; n: number; spec: ZipkinTracesSpec }
-  | { kind: 'logs'; n: number; spec: LogsSpec }
-  | { kind: 'browser-errors'; n: number; spec: BrowserErrorsSpec }
+  | { kind: 'traces'; n: number; spec: TracesSpec; capturedAt?: number }
+  | { kind: 'zipkin-traces'; n: number; spec: ZipkinTracesSpec; capturedAt?: 
number }
+  | { kind: 'logs'; n: number; spec: LogsSpec; capturedAt?: number }
+  | { kind: 'browser-errors'; n: number; spec: BrowserErrorsSpec; capturedAt?: 
number }
   | { kind: 'tool'; name: string; status: 'running' | 'done' | 'denied' };
 
 export type FigureBlock = Extract<Block, { kind: 'figure' }>;
diff --git a/apps/ui/src/ai/useAiConversations.ts 
b/apps/ui/src/ai/useAiConversations.ts
index 6629755..a14d8d6 100644
--- a/apps/ui/src/ai/useAiConversations.ts
+++ b/apps/ui/src/ai/useAiConversations.ts
@@ -242,16 +242,16 @@ async function send(text: string): Promise<void> {
       else if (ev.type === 'subpage') assistant.blocks.push({ kind: 'subpage', 
n: ev.n, spec: ev.spec });
       else if (ev.type === 'proposal') assistant.blocks.push({ kind: 
'proposal', n: ev.n, spec: ev.spec, status: 'pending' });
       else if (ev.type === 'profiling') assistant.blocks.push({ kind: 
'profiling', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
-      else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', 
n: ev.n, spec: ev.spec });
+      else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', 
n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'hierarchy') assistant.blocks.push({ kind: 
'hierarchy', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'topology') assistant.blocks.push({ kind: 
'topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'deployment') assistant.blocks.push({ kind: 
'deployment', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'instance-topology') assistant.blocks.push({ kind: 
'instance-topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'endpoint-dependency') assistant.blocks.push({ 
kind: 'endpoint-dependency', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
-      else if (ev.type === 'traces') assistant.blocks.push({ kind: 'traces', 
n: ev.n, spec: ev.spec });
-      else if (ev.type === 'zipkin-traces') assistant.blocks.push({ kind: 
'zipkin-traces', n: ev.n, spec: ev.spec });
-      else if (ev.type === 'logs') assistant.blocks.push({ kind: 'logs', n: 
ev.n, spec: ev.spec });
-      else if (ev.type === 'browser-errors') assistant.blocks.push({ kind: 
'browser-errors', n: ev.n, spec: ev.spec });
+      else if (ev.type === 'traces') assistant.blocks.push({ kind: 'traces', 
n: ev.n, spec: ev.spec, capturedAt: Date.now() });
+      else if (ev.type === 'zipkin-traces') assistant.blocks.push({ kind: 
'zipkin-traces', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
+      else if (ev.type === 'logs') assistant.blocks.push({ kind: 'logs', n: 
ev.n, spec: ev.spec, capturedAt: Date.now() });
+      else if (ev.type === 'browser-errors') assistant.blocks.push({ kind: 
'browser-errors', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
       else if (ev.type === 'error') appendToken(assistant.blocks, `\n\n⚠ 
${ev.message}`);
       else if (ev.type === 'done') break;
     }
diff --git a/apps/ui/src/components/icons/Icon.vue 
b/apps/ui/src/components/icons/Icon.vue
index 50cad00..a2bff42 100644
--- a/apps/ui/src/components/icons/Icon.vue
+++ b/apps/ui/src/components/icons/Icon.vue
@@ -57,7 +57,8 @@ export type IconName =
   | 'ai'
   | 'external'
   | 'close'
-  | 'trash';
+  | 'trash'
+  | 'replay';
 </script>
 
 <template>
@@ -254,5 +255,11 @@ export type IconName =
       <path d="M5 7h14M10 7V5h4v2M8 7l1 13h6l1-13" />
       <path d="M10.5 11v5M13.5 11v5" />
     </template>
+    <!-- history / replay: a rewound arc + a clock hand — frozen point-in-time 
data -->
+    <template v-else-if="name === 'replay'">
+      <path d="M3.5 12a8.5 8.5 0 1 0 2.4-5.9" />
+      <path d="M3 4v4h4" />
+      <path d="M12 8v4l2.5 1.5" />
+    </template>
   </svg>
 </template>
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index dfbefef..8523221 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1640,6 +1640,9 @@
   "Stop": "Stop",
   "Model": "Model",
   "Suggested action: start {type}": "Suggested action: start {type}",
+  "captured {when}": "captured {when}",
+  "captured": "captured",
+  "Captured snapshot — replayed from history, not live data": "Captured 
snapshot — replayed from history, not live data",
   "{n} segments": "{n} segments",
   "{n} frames": "{n} frames",
   "all endpoints": "all endpoints",
diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue 
b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
index 395ee29..9893b02 100644
--- a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
+++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
@@ -26,7 +26,7 @@
 import { computed, onMounted, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { useI18n } from 'vue-i18n';
-import type { BrowserErrorCategory, BrowserErrorRow, LayerDef } from 
'@/api/client';
+import type { BrowserErrorCategory, BrowserErrorRow, BrowserErrorsResponse, 
LayerDef } from '@/api/client';
 import { useLayers } from '@/shell/useLayers';
 import { useSetupStore } from '@/state/setup';
 import { useSelectedService } from '@/layer/useSelectedService';
@@ -49,10 +49,15 @@ const props = defineProps<{
   layerKey?: string;
   focusService?: string;
   focusWindowMinutes?: number;
+  /** REPLAY (chat reload): render the frozen captured error list, never query.
+   *  The mount auto-run is skipped and the Prev/Next pager is hidden. */
+  replay?: boolean;
+  replayData?: BrowserErrorsResponse | null;
 }>();
 const route = useRoute();
 const { t } = useI18n({ useScope: 'global' });
 const embedded = computed(() => Boolean(props.embedded));
+const replay = computed(() => Boolean(props.replay));
 const layerKey = computed(() =>
   props.layerKey && props.layerKey.length > 0 ? props.layerKey : 
String(route.params.layerKey ?? ''),
 );
@@ -82,7 +87,10 @@ const safeCfg = computed(() => {
     metrics: layer.value.metrics,
   }).landing;
 });
-const landing = useLayerLanding(safeLayer, safeCfg);
+// Aux feeds power the hidden version/page filter chrome; in replay they must 
fire
+// NO OAP request. Landing + endpoints take a replay gate; instances has none, 
so
+// starve its service to null in replay.
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay);
 // Embedded takes the focus service from the prop; the route resolves it from
 // the shared layerSelection store — overriding here keeps the chat block from
 // touching that global selection.
@@ -155,7 +163,8 @@ const allCategories = ref<BrowserErrorCategory>('ALL');
 // the BROWSER "Versions" are instances → serviceVersionId; "Pages" are
 // endpoints → pagePathId. Reuse the shared layer instance/endpoint feeds.
 const selectedVersionId = ref('');
-const { instances: versionList } = useLayerInstances(layerKey, serviceName);
+const toolbarService = computed(() => (replay.value ? null : 
serviceName.value));
+const { instances: versionList } = useLayerInstances(layerKey, toolbarService);
 
 // Page (endpoint) is a searchable combobox (shared EndpointCombo), not a
 // plain dropdown: OAP returns only a top-N endpoint list, so the typed
@@ -168,7 +177,7 @@ const selectedPageId = ref('');
 const selectedPageLabel = ref('');
 const pageQuery = ref('');
 const pageLimit = ref(50);
-const { endpoints: pageList, isFetching: pagesLoading } = 
useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit);
+const { endpoints: pageList, isFetching: pagesLoading } = 
useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit, replay);
 function pickPage(name: string): void {
   selectedPageId.value = pageList.value.find((p) => p.name === name)?.id ?? '';
   selectedPageLabel.value = name;
@@ -184,7 +193,9 @@ function clearPage(): void {
 // the query reads that snapshot, so it fires only on Run query — never the
 // prior service's errors on a fresh tab. page + pageSize stay live (paging
 // is a direct action, not a staged filter).
-const hasQueried = ref(false);
+// Replay seeds this true so the frozen rows render straight past the
+// "Run query" gate; the composable keeps the query itself disabled.
+const hasQueried = ref(replay.value);
 interface AppliedBrowserConditions {
   service: string | null;
   serviceVersionId: string;
@@ -219,6 +230,7 @@ const { logs, total, reachable, queryError, isFetching, 
refetch } = useLayerBrow
   startMs: computed(() => applied.value.startMs),
   endMs: computed(() => applied.value.endMs),
   enabled: hasQueried,
+  replayData: computed(() => props.replayData ?? null),
 });
 function runQuery(): void {
   applyConditions();
@@ -231,6 +243,7 @@ function runQuery(): void {
 // renders self-contained (version/page filters stay All — service-wide 
errors).
 onMounted(() => {
   if (!embedded.value) return;
+  if (replay.value) return; // frozen snapshot: no query, hasQueried is 
pre-seeded
   if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes;
   runQuery();
 });
@@ -509,7 +522,7 @@ function loc(row: BrowserErrorRow): string {
 
         <div class="lg-pager">
           <span class="hint">{{ t('page {page} · showing {shown} of {total} 
loaded', { page, shown: filteredLogs.length, total }) }}</span>
-          <div class="lg-pager-ctrls">
+          <div v-if="!replay" class="lg-pager-ctrls">
             <button class="sw-btn small" type="button" :disabled="page <= 1 || 
isFetching" @click="page--">{{ t('Prev') }}</button>
             <button class="sw-btn small" type="button" 
:disabled="!hasMorePages || isFetching" @click="page++">{{ t('Next') }}</button>
           </div>
diff --git a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts 
b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
index c5f8d84..89fb26e 100644
--- a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
+++ b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
@@ -39,9 +39,16 @@ export interface BrowserErrorParams {
   /** Gate the query — when false it never runs. Manual-fire holds it until
    *  the operator presses Run query. Defaults to always-on. */
   enabled?: Ref<boolean>;
+  /** REPLAY mode: the captured error list to render from. When present the
+   *  query NEVER fetches — a reloaded conversation replays the exact rows with
+   *  zero OAP round-trip. Rendered locally below, not seeded into the query
+   *  cache, so a chat snapshot can't leak into a live view. */
+  replayData?: Ref<BrowserErrorsResponse | null>;
 }
 
 export function useLayerBrowserErrors(layerKey: Ref<string>, params: 
BrowserErrorParams) {
+  // replay mode is on whenever captured data is supplied.
+  const replay = computed(() => !!params.replayData?.value);
   const q = useQuery<BrowserErrorsResponse>({
     queryKey: [
       'layer-browser-errors',
@@ -72,18 +79,27 @@ export function useLayerBrowserErrors(layerKey: 
Ref<string>, params: BrowserErro
         page: params.page.value,
         pageSize: params.pageSize.value,
       }),
-    enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true)),
+    // Replay is static: never fetch (data comes from replayData below).
+    enabled: computed(
+      () => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true) && !replay.value,
+    ),
     staleTime: 15_000,
   });
   // No auto-refresh subscription: this is a triage page that owns its time
   // range, and the topbar suspends the global ticker for it ("Paused").
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed<BrowserErrorsResponse | null>(() =>
+    replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null),
+  );
   return {
-    data: computed(() => q.data.value ?? null),
-    logs: computed(() => q.data.value?.logs ?? []),
-    total: computed(() => q.data.value?.total ?? 0),
-    reachable: computed(() => q.data.value?.reachable ?? true),
-    queryError: computed(() => q.data.value?.error ?? null),
+    data,
+    logs: computed(() => data.value?.logs ?? []),
+    total: computed(() => data.value?.total ?? 0),
+    reachable: computed(() => data.value?.reachable ?? true),
+    queryError: computed(() => data.value?.error ?? null),
     isFetching: q.isFetching,
     error: q.error,
     refetch: q.refetch,
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index 63a52d2..08d8a36 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -27,7 +27,7 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
-import type { LayerDef, LogRow, LogTagFilter } from '@/api/client';
+import type { LayerDef, LogRow, LogsResponse, LogTagFilter } from 
'@/api/client';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useLayerLogs, useLayerLogFacets } from '@/layer/logs/useLayerLogs';
 import { useLayerInstances } from '@/layer/useLayerInstances';
@@ -56,9 +56,15 @@ const props = defineProps<{
   layerKey?: string;
   focusService?: string;
   focusWindowMinutes?: number;
+  /** REPLAY mode: render the captured log list with zero re-query — no mount
+   *  auto-run, no pager (paging would refetch). Implies embedded. */
+  replay?: boolean;
+  replayData?: LogsResponse | null;
 }>();
 const route = useRoute();
 const embedded = computed(() => Boolean(props.embedded));
+const replay = computed(() => Boolean(props.replay));
+const replayDataRef = computed<LogsResponse | null>(() => props.replayData ?? 
null);
 const layerKey = computed(() =>
   props.layerKey && props.layerKey.length > 0 ? props.layerKey : 
String(route.params.layerKey ?? ''),
 );
@@ -78,7 +84,10 @@ const safeCfg = computed(() => {
     slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
-const landing = useLayerLanding(safeLayer, safeCfg);
+// The landing / instance / endpoint feeds power the hidden filter chrome; in
+// replay they must fire NO OAP request. Landing + endpoints take a replay 
gate;
+// instances has none, so starve its service to null in replay.
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay);
 // Embedded takes the focus service from the prop; the route resolves it from
 // the shared layerSelection store — overriding here means the chat block never
 // touches that global selection.
@@ -120,7 +129,8 @@ const showEndpointSelector = computed(() => logScope.value 
!== 'endpoint');
 // How the picked instance is used depends on `logScope`: pinned primary
 // selector under `instance` scope, optional narrower otherwise.
 const { selectedInstance, setSelectedInstance } = useSelectedInstance();
-const { instances: instanceList } = useLayerInstances(layerKey, serviceName);
+const toolbarService = computed(() => (replay.value ? null : 
serviceName.value));
+const { instances: instanceList } = useLayerInstances(layerKey, 
toolbarService);
 // Logs (and traces) intentionally do NOT auto-select an instance.
 // Default is `All` so the stream starts broad; the operator opts into
 // narrowing by picking from the dropdown. Auto-selection is reserved
@@ -156,6 +166,7 @@ const { endpoints: endpointList, isFetching: 
endpointsLoading } = useLayerEndpoi
   serviceName,
   endpointQuery,
   endpointLimit,
+  replay,
 );
 // No endpoint auto-pick on Logs either — same reasoning as the
 // instance picker above. Default is `All`; operator narrows by hand.
@@ -291,6 +302,7 @@ const { logs, total, isFetching, error, refetch } = 
useLayerLogs(layerKey, {
   startMs: aStartMs,
   endMs: aEndMs,
   enabled: hasQueried,
+  replayData: replayDataRef,
 });
 
 const { facets, refetch: refetchFacets } = useLayerLogFacets(layerKey, {
@@ -303,6 +315,7 @@ const { facets, refetch: refetchFacets } = 
useLayerLogFacets(layerKey, {
   startMs: aStartMs,
   endMs: aEndMs,
   enabled: hasQueried,
+  replay,
 });
 
 // Run query refetches BOTH the log stream and the facet sample (level
@@ -321,6 +334,11 @@ function runQuery(): void {
 onMounted(() => {
   if (!embedded.value) return;
   if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes;
+  // Replay renders the captured rows — flip past the Run-query prompt, never 
fetch.
+  if (replay.value) {
+    hasQueried.value = true;
+    return;
+  }
   runQuery();
 });
 
@@ -537,8 +555,11 @@ watch(
           @jump-trace="jumpToTrace($event.traceId, $event.ts)"
         />
         <div class="lg-pager">
-          <span class="hint">page {{ page }} · showing {{ filteredLogs.length 
}} of {{ total }} total</span>
-          <div class="lg-pager-ctrls">
+          <span class="hint">
+            <template v-if="replay">showing {{ filteredLogs.length }} of {{ 
total }} captured</template>
+            <template v-else>page {{ page }} · showing {{ filteredLogs.length 
}} of {{ total }} total</template>
+          </span>
+          <div v-if="!replay" class="lg-pager-ctrls">
             <button class="sw-btn small" type="button" :disabled="page <= 1" 
@click="page--">Prev</button>
             <button
               class="sw-btn small"
diff --git a/apps/ui/src/layer/logs/useLayerLogs.ts 
b/apps/ui/src/layer/logs/useLayerLogs.ts
index 3cd98bd..da1e068 100644
--- a/apps/ui/src/layer/logs/useLayerLogs.ts
+++ b/apps/ui/src/layer/logs/useLayerLogs.ts
@@ -35,9 +35,15 @@ export interface LogListParams {
   /** Gate the query — when false it never runs. Manual-fire pages hold it
    *  until the operator presses Run query. Defaults to always-on. */
   enabled?: Ref<boolean>;
+  /** REPLAY mode: the captured log list to render from. When present the query
+   *  NEVER fetches — a reloaded chat block replays the exact rows with zero 
OAP
+   *  round-trip, so it can't page to a fresh window and survives an offline 
OAP. */
+  replayData?: Ref<LogsResponse | null>;
 }
 
 export function useLayerLogs(layerKey: Ref<string>, params: LogListParams) {
+  // replay mode is on whenever captured data is supplied.
+  const replay = computed(() => !!params.replayData?.value);
   const q = useQuery<LogsResponse>({
     queryKey: [
       'layer-logs',
@@ -69,14 +75,22 @@ export function useLayerLogs(layerKey: Ref<string>, params: 
LogListParams) {
         page: params.page.value,
         pageSize: params.pageSize.value,
       }),
-    enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true)),
+    // Replay is static: never fetch (rows come from replayData below).
+    enabled: computed(
+      () => layerKey.value.length > 0 && !replay.value && (params.enabled ? 
params.enabled.value : true),
+    ),
     staleTime: 15_000,
   });
 
+  // Replay renders straight from the captured payload — NOT the query cache, 
so a
+  // chat snapshot can't serve a live view (or vice-versa) during staleTime.
+  const data = computed<LogsResponse | null>(() =>
+    replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null),
+  );
   return {
-    data: computed(() => q.data.value ?? null),
-    logs: computed(() => q.data.value?.logs ?? []),
-    total: computed(() => q.data.value?.total ?? 0),
+    data,
+    logs: computed(() => data.value?.logs ?? []),
+    total: computed(() => data.value?.total ?? 0),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
@@ -100,6 +114,9 @@ export interface LogFacetParams {
   startMs?: Ref<number | null>;
   endMs?: Ref<number | null>;
   enabled?: Ref<boolean>;
+  /** REPLAY mode: gate the facet fetch off — the view falls back to 
client-side
+   *  level counts over the replayed rows, so no OAP sample is needed offline. 
*/
+  replay?: Ref<boolean>;
 }
 
 export function useLayerLogFacets(layerKey: Ref<string>, params: 
LogFacetParams) {
@@ -129,7 +146,12 @@ export function useLayerLogFacets(layerKey: Ref<string>, 
params: LogFacetParams)
           : {}),
         sampleSize: 200,
       }),
-    enabled: computed(() => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true)),
+    enabled: computed(
+      () =>
+        layerKey.value.length > 0 &&
+        !(params.replay?.value ?? false) &&
+        (params.enabled ? params.enabled.value : true),
+    ),
     staleTime: 30_000,
   });
   return {
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue 
b/apps/ui/src/layer/traces/LayerTracesView.vue
index 6d3f409..a5b6136 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -42,6 +42,7 @@ import type {
   LayerDef,
   NativeSpan,
   NativeTraceListRow,
+  TraceListResponse,
   TraceQueryOrder,
   TraceQueryState,
 } from '@/api/client';
@@ -69,9 +70,15 @@ const props = defineProps<{
   focusEndpointId?: string;
   focusInstanceId?: string;
   focusWindowMinutes?: number;
+  /** REPLAY (captured chat): render the frozen list from `replayData` and 
fire NO
+   *  OAP query — no auto-run, no aux toolbar fetches, no pager. */
+  replay?: boolean;
+  replayData?: TraceListResponse | null;
 }>();
 const route = useRoute();
 const embedded = computed(() => Boolean(props.embedded));
+const replay = computed(() => Boolean(props.replay));
+const replayDataRef = computed<TraceListResponse | null>(() => 
props.replayData ?? null);
 const layerKey = computed(() =>
   props.layerKey && props.layerKey.length > 0 ? props.layerKey : 
String(route.params.layerKey ?? ''),
 );
@@ -90,7 +97,8 @@ const safeCfg = computed(() => {
     slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
-const landing = useLayerLanding(safeLayer, safeCfg);
+// Replay hides the whole toolbar this rollup feeds, so it fires zero queries.
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, replay);
 // Embedded mode takes the focus service straight from the prop; the route uses
 // the shared layerSelection store (resolved to a name). Overriding here means
 // the chat block never touches that global selection.
@@ -184,8 +192,12 @@ watch(isCustomRange, (custom) => {
 const NATIVE_SOURCE = ref<'native'>('native');
 const sourceRef = computed<'native'>(() => 'native');
 
-const { instances } = useLayerInstances(layerKey, serviceName);
-const { endpoints } = useLayerEndpoints(layerKey, serviceName, endpointQuery, 
ref(50));
+// The instance/endpoint lists feed the hidden filter chrome; in replay they 
must
+// fire NO query. Endpoints takes a replay gate; instances has none, so we 
starve
+// it of a service (its `enabled` needs one).
+const toolbarService = computed(() => (replay.value ? null : 
serviceName.value));
+const { instances } = useLayerInstances(layerKey, toolbarService);
+const { endpoints } = useLayerEndpoints(layerKey, toolbarService, 
endpointQuery, ref(50), replay);
 // '' ↔ null bridges the All sentinel (TypeaheadSelect value is a string).
 const instanceSelectOptions = computed(() => [
   { value: '', label: t('All') },
@@ -234,6 +246,7 @@ const { native, isFetching, refetch } = 
useLayerTraces(layerKey, {
   customStart: cCustomStart,
   customEnd: cCustomEnd,
   enabled: queryEnabled,
+  replayData: replayDataRef,
 });
 
 // Which OAP query answered. `queryBasicTraces` (Trace Query v1 API)
@@ -343,6 +356,12 @@ watch(() => route.query.dNonce, applyDrillFromRoute, { 
immediate: true });
 // query on mount, so the block renders self-contained without a Run-query 
click.
 onMounted(() => {
   if (!embedded.value) return;
+  // Replay: render the captured list (query stays disabled, data comes from
+  // replayData) — flip `hasQueried` so the list path shows, but fire nothing.
+  if (replay.value) {
+    hasQueried.value = true;
+    return;
+  }
   if (props.focusInstanceId) instanceId.value = props.focusInstanceId;
   if (props.focusEndpointId) endpointId.value = props.focusEndpointId;
   if (props.focusWindowMinutes) windowMinutes.value = props.focusWindowMinutes;
@@ -372,7 +391,7 @@ function changeSelectedTraceId(id: string): void {
 }
 
 const traceIdRef = computed(() => selectedTraceId.value);
-const { nativeDetail, isFetching: detailFetching } = 
useTraceDetail(traceIdRef, sourceRef);
+const { nativeDetail, isFetching: detailFetching } = 
useTraceDetail(traceIdRef, sourceRef, undefined, replay);
 // Spans fed to the shared detail card: list-embedded (`queryTraces`)
 // when present, else the on-demand `queryTrace` fetch.
 const detailSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? 
nativeDetail.value?.spans ?? []);
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue 
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index 3df956c..8687c20 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -25,7 +25,12 @@
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute } from 'vue-router';
-import type { NativeTraceListRow, ZipkinSpan, ZipkinTraceListRow } from 
'@skywalking-horizon-ui/api-client';
+import type {
+  NativeTraceListRow,
+  ZipkinSpan,
+  ZipkinTraceListResponse,
+  ZipkinTraceListRow,
+} from '@skywalking-horizon-ui/api-client';
 
 const { t } = useI18n({ useScope: 'global' });
 import { useLayerZipkinTraces, useZipkinTrace } from 
'@/layer/traces/useZipkinTraces';
@@ -57,8 +62,12 @@ const props = defineProps<{
    *  assistant from list_zipkin_services — it differs from the SkyWalking 
name. */
   focusService?: string;
   focusWindowMinutes?: number;
+  /** REPLAY (chat reload): render the captured list+spans, never re-query. */
+  replay?: boolean;
+  replayData?: ZipkinTraceListResponse | null;
 }>();
 const embedded = computed(() => Boolean(props.embedded));
+const replay = computed(() => Boolean(props.replay));
 
 const route = useRoute();
 const layerKey = computed(() =>
@@ -140,6 +149,7 @@ const { traces, isFetching, error, refetch } = 
useLayerZipkinTraces({
   limit: cLimit,
   annotationQuery: cAnno,
   enabled: queryEnabled,
+  replayData: computed(() => props.replayData ?? null),
 });
 const { openTrace } = useZipkinTracePopout();
 
@@ -198,8 +208,8 @@ const {
   spanName,
   remoteServiceName,
   // Embedded (chat) hides the whole toolbar, so skip the autocomplete fetches
-  // that only feed its dropdowns.
-  enabled: !embedded.value,
+  // that only feed its dropdowns. Replay must never re-query OAP either.
+  enabled: !embedded.value && !replay.value,
 });
 
 const serviceSelectOptions = computed(() => [
@@ -247,7 +257,7 @@ const selectedRowSpans = computed<ZipkinSpan[] | null>(() 
=> {
 const fallbackTraceId = computed<string | null>(() =>
   selectedTraceId.value && !selectedRowSpans.value ? selectedTraceId.value : 
null,
 );
-const { spans: fetchedSpans, isLoading: fetchLoading } = 
useZipkinTrace(fallbackTraceId);
+const { spans: fetchedSpans, isLoading: fetchLoading } = 
useZipkinTrace(fallbackTraceId, replay);
 const selectedSpans = computed<ZipkinSpan[]>(() => selectedRowSpans.value ?? 
fetchedSpans.value);
 const selectedLoading = computed<boolean>(() => Boolean(fallbackTraceId.value) 
&& fetchLoading.value);
 
@@ -278,6 +288,12 @@ onMounted(() => {
   if (props.focusWindowMinutes && props.focusWindowMinutes > 0) {
     lookbackMs.value = props.focusWindowMinutes * 60_000;
   }
+  // Replay renders the captured list straight from replayData — mark queried 
so
+  // the results show, but never fire the query.
+  if (replay.value) {
+    hasQueried.value = true;
+    return;
+  }
   runQuery();
 });
 
diff --git a/apps/ui/src/layer/traces/useLayerTraces.ts 
b/apps/ui/src/layer/traces/useLayerTraces.ts
index 723ba53..686c587 100644
--- a/apps/ui/src/layer/traces/useLayerTraces.ts
+++ b/apps/ui/src/layer/traces/useLayerTraces.ts
@@ -57,11 +57,18 @@ export interface TraceListParams {
    *  skip fetching while the popout is open (shareable URLs land on
    *  trace detail; no need to also pull a list page). */
   enabled?: Ref<boolean>;
+  /** REPLAY: a captured list to render from. Present ⇒ the query never fires 
and
+   *  data/native/zipkin come straight from it — a reloaded chat replays the 
exact
+   *  list (with inline spans on v2) with zero OAP round-trip. */
+  replayData?: Ref<TraceListResponse | null>;
 }
 
 export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) 
{
   // Preview-only: forward the draft `traces` block (source selector).
   const previewCfg = usePreviewLayerBlock(layerKey, 'traces');
+  // Replay is on whenever a captured list is supplied — the query stays 
disabled
+  // and data is served straight from replayData below.
+  const replay = computed(() => !!params.replayData?.value);
   const q = useQuery<TraceListResponse>({
     queryKey: [
       'layer-traces',
@@ -108,14 +115,22 @@ export function useLayerTraces(layerKey: Ref<string>, 
params: TraceListParams) {
         ...(previewCfg.value ? { previewConfig: previewCfg.value } : {}),
       }),
     enabled: computed(
-      () => layerKey.value.length > 0 && (params.enabled ? 
params.enabled.value : true),
+      () =>
+        layerKey.value.length > 0 &&
+        (params.enabled ? params.enabled.value : true) &&
+        !replay.value,
     ),
     staleTime: 15_000,
   });
+  // Replay renders straight from the captured payload — NOT through the query
+  // cache (seeding it would contaminate live trace views during staleTime).
+  const list = computed<TraceListResponse | null>(() =>
+    replay.value ? (params.replayData?.value ?? null) : (q.data.value ?? null),
+  );
   return {
-    data: computed(() => q.data.value ?? null),
-    native: computed(() => q.data.value?.native ?? null),
-    zipkin: computed(() => q.data.value?.zipkin ?? null),
+    data: list,
+    native: computed(() => list.value?.native ?? null),
+    zipkin: computed(() => list.value?.zipkin ?? null),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
@@ -138,6 +153,9 @@ export function useTraceDetail(
    *  this lets cold-tier trace IDs resolve. When null, the BFF
    *  defaults to OAP's last-1-day `queryTrace` window. */
   atMs?: Ref<number | null>,
+  /** REPLAY: gate the on-demand detail fetch off. Captured v2 rows carry 
inline
+   *  spans (detail renders with no query); v1 has no offline detail — 
acceptable. */
+  replay?: Ref<boolean>,
 ) {
   const rangeKey = computed(() => atMs?.value ?? null);
   const q = useQuery<TraceDetailResponse>({
@@ -154,7 +172,7 @@ export function useTraceDetail(
           : undefined;
       return bffClient.trace.detail(traceId.value!, source.value, range);
     },
-    enabled: computed(() => !!traceId.value),
+    enabled: computed(() => !!traceId.value && !(replay?.value ?? false)),
     staleTime: 60_000,
   });
   return {
diff --git a/apps/ui/src/layer/traces/useZipkinTraces.ts 
b/apps/ui/src/layer/traces/useZipkinTraces.ts
index 20b6527..f873933 100644
--- a/apps/ui/src/layer/traces/useZipkinTraces.ts
+++ b/apps/ui/src/layer/traces/useZipkinTraces.ts
@@ -50,13 +50,20 @@ export interface ZipkinTracesParams {
   /** Run only when the user has clicked Run query — mirrors the native
    *  trace tab's "explicit fetch" model. */
   enabled?: Ref<boolean>;
+  /** REPLAY mode: the captured list (with inline spans) to render from. When
+   *  present the query NEVER fetches — a reloaded conversation replays the 
exact
+   *  data with zero OAP round-trip and survives an offline OAP. */
+  replayData?: Ref<ZipkinTraceListResponse | null>;
 }
 
 export function useLayerZipkinTraces(params: ZipkinTracesParams) {
+  // Replay is on whenever captured data is supplied.
+  const replay = computed(() => !!params.replayData?.value);
   // Zipkin allows "all services" — a blank serviceName queries every
   // local endpoint. Only gate on the operator's explicit `enabled`
-  // flag (Run query click), not on serviceName presence.
-  const enabled = computed(() => (params.enabled ? params.enabled.value : 
true));
+  // flag (Run query click), not on serviceName presence. Replay is static:
+  // never fetch (data comes from replayData below).
+  const enabled = computed(() => (params.enabled ? params.enabled.value : 
true) && !replay.value);
   const q = useQuery<ZipkinTraceListResponse>({
     queryKey: [
       'zipkin-traces',
@@ -87,9 +94,13 @@ export function useLayerZipkinTraces(params: 
ZipkinTracesParams) {
     enabled,
     staleTime: 15_000,
   });
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed(() => (replay.value ? (params.replayData?.value ?? 
null) : (q.data.value ?? null)));
   return {
-    data: computed(() => q.data.value ?? null),
-    traces: computed(() => q.data.value?.traces ?? []),
+    data,
+    traces: computed(() => data.value?.traces ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
@@ -97,11 +108,12 @@ export function useLayerZipkinTraces(params: 
ZipkinTracesParams) {
   };
 }
 
-export function useZipkinTrace(traceId: Ref<string | null>) {
+export function useZipkinTrace(traceId: Ref<string | null>, replay?: 
Ref<boolean>) {
   const q = useQuery<ZipkinTraceDetailResponse>({
     queryKey: ['zipkin-trace', traceId],
     queryFn: () => bffClient.zipkin.trace(traceId.value!),
-    enabled: computed(() => Boolean(traceId.value)),
+    // Replay rows carry their spans inline, so the by-id fallback never fires.
+    enabled: computed(() => Boolean(traceId.value) && !replay?.value),
     staleTime: 60_000,
   });
   return {


Reply via email to