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 dd8526f3107df301e1255d7c1e4d87e626da5ce7
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 15:33:08 2026 +0800

    refactor(ai): unify map + figure static mode as replay/replayData, 
frozen-always, zero-query
    
    One vocabulary for the captured/static-file mode across all AI-chat map 
components
    and figures: `replay` (boolean) + `replayData` (the captured payload) + 
capturedAt,
    with every disabled thing (main query, landing, roster, endpoint list, 
click-detail,
    node-expand, hierarchy probe) gated by `replay`. Replaces the scattered
    snapshot/seed/initialData naming.
    
    Frozen-always: a captured block is a static file of what was read — the map 
tools
    ALWAYS attach replayData, and an empty / unreachable / too-large / 
no-endpoint read
    replays as a clean no-value state ("no data" / "unreachable" / "too large" 
/ "No
    API-dependency data captured"), never re-queries. Figures get the same 
no-value
    state ("No data in the captured window" / "read failed at capture"). This 
reverses
    the earlier omit-on-unreachable self-heal in favour of faithful 
point-in-time replay.
    
    A replay map now fires ZERO OAP requests on mount or click — the 
picker-support
    queries (landing / roster / endpoint list) AND the endpoint node-expand 
handle are
    gated, so a reloaded map (or one whose OAP is down) stays fully offline.
    
    Also: adaptive B/KB/MB history-usage meter (was frozen at "0.0" MB for 
KB-scale
    usage); the topology "no metric data" message no longer stacks under the
    "OAP unreachable" banner.
---
 apps/bff/src/ai/resources/CLAUDE.md                |  2 +-
 apps/bff/src/ai/skill/visualization/tools.test.ts  | 22 +++++++------
 apps/bff/src/ai/skill/visualization/tools.ts       | 24 +++++++++------
 apps/bff/src/ai/types.ts                           | 12 ++++----
 apps/ui/src/ai/AiFullPageView.vue                  | 11 +++++--
 apps/ui/src/ai/ChatDeploymentBlock.vue             |  3 +-
 apps/ui/src/ai/ChatEndpointDependencyBlock.vue     |  3 +-
 apps/ui/src/ai/ChatHierarchyBlock.vue              |  3 +-
 apps/ui/src/ai/ChatInstanceTopologyBlock.vue       |  3 +-
 apps/ui/src/ai/ChatTopologyBlock.vue               |  3 +-
 apps/ui/src/ai/ChatWidgetRenderer.vue              | 34 +++++++++++++++++++-
 apps/ui/src/ai/types.ts                            | 14 ++++-----
 .../LayerEndpointDependencyView.vue                | 36 +++++++++++++---------
 .../useLayerEndpointDependency.ts                  | 13 ++++----
 .../src/layer/service-map/LayerDeploymentView.vue  | 11 ++++---
 .../service-map/LayerInstanceTopologyView.vue      | 17 +++++-----
 .../src/layer/service-map/LayerServiceMapView.vue  | 33 +++++++++++---------
 .../layer/service-map/ServiceHierarchyOverlay.vue  | 11 ++++---
 apps/ui/src/layer/service-map/useDeployment.ts     | 15 ++++-----
 .../src/layer/service-map/useInstanceTopology.ts   | 15 ++++-----
 apps/ui/src/layer/service-map/useLayerTopology.ts  | 22 +++++++------
 .../src/layer/service-map/useServiceHierarchy.ts   | 11 ++++---
 apps/ui/src/layer/useLayerEndpoints.ts             |  6 +++-
 apps/ui/src/layer/useLayerLanding.ts               | 10 +++++-
 apps/ui/src/layer/useLayerServices.ts              | 11 ++++---
 25 files changed, 214 insertions(+), 131 deletions(-)

diff --git a/apps/bff/src/ai/resources/CLAUDE.md 
b/apps/bff/src/ai/resources/CLAUDE.md
index dea54a9..3feebf0 100644
--- a/apps/bff/src/ai/resources/CLAUDE.md
+++ b/apps/bff/src/ai/resources/CLAUDE.md
@@ -55,7 +55,7 @@ The metric-vs-record and sampled-vs-topN split is IMPLICIT in 
the type + the out
 
 A persisted conversation must re-render on reload from what was captured — not 
a fresh live query. Because the components above are code-STATIC, a reloaded 
block seeds the SAME renderer with the captured (template/OAP-resolved) data 
and draws identically. So: rich reads capture the WHOLE component response 
(nodes+edges WITH metrics), and static-reload seeds the real view from it — 
never a bespoke second renderer. Any new component/tool must be seedable the 
same way (its data-in path take [...]
 
-This is IMPLEMENTED for figures AND all five MAP components. The five map 
tools resolve the full `*Response` through a shared `logic/oap/` builder (the 
same fan-out the layer route uses) and attach it as `spec.snapshot`; the 
embedded `Layer*View` seeds from it (vue-query `initialData` + `enabled:false`, 
ticker skipped), so a reloaded map replays the exact graph + its edge sparkline 
part-graphs with ZERO OAP fetch — offline-safe, frozen to its capture instant 
(stamped "captured &lt;when&g [...]
+This is IMPLEMENTED for figures AND all five MAP components under ONE 
consistent vocabulary: **`replay` (boolean) + `replayData` (the captured 
payload) + `capturedAt`**, and every disabled/gated thing is gated by `replay`. 
The five map tools resolve the full `*Response` through a shared `logic/oap/` 
builder (the same fan-out the layer route uses) and attach it as 
`spec.replayData`; the chat block mounts the embedded `Layer*View` with 
`replay=true`, and in replay mode the view renders fro [...]
 
 ## Maintaining the skills
 
diff --git a/apps/bff/src/ai/skill/visualization/tools.test.ts 
b/apps/bff/src/ai/skill/visualization/tools.test.ts
index c54058f..5581011 100644
--- a/apps/bff/src/ai/skill/visualization/tools.test.ts
+++ b/apps/bff/src/ai/skill/visualization/tools.test.ts
@@ -22,7 +22,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 vi.mock('../../../logic/dashboard/run.js', () => ({ runWidgets: vi.fn() }));
 vi.mock('../../../logic/oap/hierarchy.js', () => ({ getServiceHierarchy: 
vi.fn() }));
 // The map tools now call the shared logic/oap builders (the same fan-out the
-// layer routes use) and attach the response as spec.snapshot.
+// layer routes use) and attach the response as spec.replayData.
 vi.mock('../../../logic/oap/service-topology.js', () => ({ 
buildServiceTopology: vi.fn() }));
 vi.mock('../../../logic/oap/instance-topology.js', () => ({ 
buildInstanceTopology: vi.fn() }));
 vi.mock('../../../logic/oap/deployment.js', () => ({ buildDeployment: vi.fn() 
}));
@@ -155,12 +155,12 @@ describe('visualization render tools', () => {
     expect(getHierarchy).toHaveBeenCalledWith(expect.anything(), 'svc-1', 
'GENERAL', undefined);
     const spec = emitHierarchy.mock.calls[0][0] as {
       groups: Array<{ layer: string; peers: Array<{ role: string }> }>;
-      snapshot?: unknown;
+      replayData?: unknown;
     };
     expect(spec.groups.map((g) => g.layer)).toEqual(['GENERAL', 
'K8S_SERVICE']);
     expect(spec.groups[0].peers[0].role).toBe('self');
     // Reachable ⇒ the raw hierarchy rides along as the seed for static replay.
-    expect(spec.snapshot).toBeDefined();
+    expect(spec.replayData).toBeDefined();
   });
 
   it('show_topology runs the depth-1 builder and emits the ego graph + 
snapshot', async () => {
@@ -191,16 +191,16 @@ describe('visualization render tools', () => {
     const spec = emitTopology.mock.calls[0][0] as {
       upstream: Array<{ name: string }>;
       downstream: Array<{ name: string; isReal: boolean }>;
-      snapshot?: unknown;
+      replayData?: unknown;
     };
     // call target === focus ⇒ its source is upstream; call source === focus ⇒ 
downstream.
     expect(spec.upstream[0].name).toBe('agent::gateway');
     expect(spec.downstream[0].isReal).toBe(false);
-    expect(spec.snapshot).toBeDefined();
+    expect(spec.replayData).toBeDefined();
     expect(out).toMatch(/upstream caller/i);
   });
 
-  it('show_topology omits the snapshot on an unreachable read (so the view can 
self-heal)', async () => {
+  it('show_topology still carries the snapshot on an unreachable read (frozen 
replay of the no-value state)', async () => {
     buildTopo.mockResolvedValue({
       layer: 'GENERAL', service: 'svc-1', depth: 1, reachable: false, 
generatedAt: 0,
       config: { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] 
},
@@ -208,8 +208,10 @@ describe('visualization render tools', () => {
     });
     const { ctx, emitTopology } = mockCtx();
     const out = String(await byName(ctx).show_topology.invoke({ layer: 
'GENERAL', service: 'agent::songs' }));
-    const spec = emitTopology.mock.calls[0][0] as { snapshot?: unknown };
-    expect(spec.snapshot).toBeUndefined();
+    const spec = emitTopology.mock.calls[0][0] as { replayData?: { reachable?: 
boolean } };
+    // Always frozen — the block replays the captured (unreachable) state, 
never re-queries.
+    expect(spec.replayData).toBeDefined();
+    expect(spec.replayData?.reachable).toBe(false);
     expect(out).toMatch(/unreachable/i);
   });
 
@@ -236,7 +238,7 @@ describe('visualization render tools', () => {
       serverService: string;
       serverServiceId: string;
       windowMinutes?: number;
-      snapshot?: unknown;
+      replayData?: unknown;
     };
     // source → client, dest → server (must NOT be swapped).
     expect(spec.clientService).toBe('agent::gateway');
@@ -244,7 +246,7 @@ describe('visualization render tools', () => {
     expect(spec.serverService).toBe('agent::songs');
     expect(spec.serverServiceId).toBe('svc-1');
     expect(spec.windowMinutes).toBe(10); // (endMs 600_000 - 0) / 60_000
-    expect(spec.snapshot).toBeDefined();
+    expect(spec.replayData).toBeDefined();
     expect(out).toContain('agent::gateway');
   });
 
diff --git a/apps/bff/src/ai/skill/visualization/tools.ts 
b/apps/bff/src/ai/skill/visualization/tools.ts
index 3a908df..6cdc716 100644
--- a/apps/bff/src/ai/skill/visualization/tools.ts
+++ b/apps/bff/src/ai/skill/visualization/tools.ts
@@ -437,9 +437,9 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         groups,
         reachable: res.reachable,
         errorReason: res.reachable ? null : (res.error ?? 'hierarchy 
unreachable'),
-        // Carry the raw hierarchy so the embedded overlay seeds statically (no
-        // re-query on reload). Structural fan — no metrics, no series, tiny.
-        snapshot: res.reachable ? res : undefined,
+        // Always frozen: carry the raw hierarchy so the embedded overlay 
replays
+        // statically (or its empty/unreachable state); it never re-queries.
+        replayData: res,
       });
       const peerCount = groups.reduce((n, g) => n + g.peers.filter((p) => 
p.role !== 'self').length, 0);
       return res.reachable
@@ -516,10 +516,11 @@ export function visualizationTools(ctx: 
AiRequestContext): StructuredToolInterfa
         reachable: snapshot.reachable,
         errorReason: snapshot.reachable ? null : (snapshot.error ?? 'topology 
unreachable'),
         windowMinutes,
-        // Attach the snapshot ONLY on a clean, reachable graph — a too-large 
fan
-        // (guard dropped the graph) or an unreachable read emit NO snapshot 
so the
-        // view degrades to a live fetch that can self-heal, matching the 
siblings.
-        snapshot: tooLarge || !snapshot.reachable ? undefined : snapshot,
+        // ALWAYS carry the replayData — the block is a static file of what 
was read.
+        // If the read had no value (empty / unreachable / too-large), the 
seeded
+        // view replays THAT state ("no data" / "unreachable" / "too large"),
+        // frozen; it never re-queries on reload.
+        replayData: snapshot,
       });
       if (!snapshot.reachable) return `Topology for ${service} is unreachable 
(${snapshot.error ?? 'no data'}).`;
       if (tooLarge) return `Topology for ${service} is too large to draw 
legibly (${snapshot.tooLarge!.nodes} nodes, ${snapshot.tooLarge!.edges} edges). 
Narrow the scope.`;
@@ -568,7 +569,8 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         service,
         serviceId: row.id,
         windowMinutes,
-        snapshot: snapshot.reachable ? snapshot : undefined,
+        // Always frozen: replay the captured graph (or its empty/unreachable 
state).
+        replayData: snapshot,
       });
       if (!snapshot.reachable) return `Deployment for ${service} is 
unreachable (${snapshot.error ?? 'no data'}).`;
       // A role-clustered deployment (e.g. BanyanDB) keeps its metric defs 
under
@@ -629,7 +631,8 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         serverService: destService,
         serverServiceId: server.id,
         windowMinutes,
-        snapshot: snapshot.reachable ? snapshot : undefined,
+        // Always frozen: replay the captured pair map (or its 
empty/unreachable state).
+        replayData: snapshot,
       });
       if (!snapshot.reachable) return `The instance map for ${sourceService} → 
${destService} is unreachable (${snapshot.error ?? 'no data'}).`;
       if (snapshot.nodes.length === 0) return `${sourceService} → 
${destService}: no instance-level call relationship in this window (empty 
map).`;
@@ -682,7 +685,8 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
         service,
         serviceId: row.id,
         windowMinutes,
-        snapshot: snapshot.reachable && snapshot.endpointId ? snapshot : 
undefined,
+        // Always frozen: replay the captured chain (or its empty/no-endpoint 
state).
+        replayData: snapshot,
       });
       if (!snapshot.reachable) return `API dependency for ${service} is 
unreachable (${snapshot.error ?? 'no data'}).`;
       if (!snapshot.endpointId) return `${service} exposes no endpoints in 
this window (no dependency chain to draw).`;
diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts
index 184b358..1a906c9 100644
--- a/apps/bff/src/ai/types.ts
+++ b/apps/bff/src/ai/types.ts
@@ -139,7 +139,7 @@ export interface HierarchySpec {
   /** The captured raw hierarchy (the overlay's native shape). Present ⇒ the
    *  embedded overlay SEEDS from it and never re-queries — a reloaded fan
    *  replays statically. `groups` stays for the LLM text summary. */
-  snapshot?: ServiceHierarchyResponse;
+  replayData?: ServiceHierarchyResponse;
 }
 
 /** A direct neighbour of the focus service in the one-hop ego topology.
@@ -172,7 +172,7 @@ export interface TopologySpec {
    *  series). When present the embedded view SEEDS from it and never 
re-queries
    *  OAP — so a reloaded conversation replays the exact point-in-time map + 
its
    *  edge part-graphs. Absent (too-large / degrade) ⇒ the view live-fetches. 
*/
-  snapshot?: TopologyResponse;
+  replayData?: TopologyResponse;
 }
 
 /** A mounted DEPLOYMENT view — the real per-service instance-to-instance call
@@ -188,7 +188,7 @@ export interface DeploymentSpec {
   /** Captured render-ready graph (instances + intra-service edges WITH metric
    *  values + twin edge series). Present ⇒ the embedded view seeds statically
    *  and replays the edge part-graphs on reload; absent ⇒ live fetch. */
-  snapshot?: DeploymentResponse;
+  replayData?: DeploymentResponse;
 }
 
 /** A mounted INSTANCE-TOPOLOGY view — the real per-pair instance map embedded
@@ -205,8 +205,8 @@ export interface InstanceTopologySpec {
   serverServiceId: string;
   windowMinutes?: number;
   /** Captured render-ready pair map (instances + edges WITH metric values +
-   *  twin edge series). Present ⇒ seed statically + replay edge part-graphs. 
*/
-  snapshot?: InstanceTopologyResponse;
+   *  twin edge series). Present ⇒ replay statically + edge part-graphs. */
+  replayData?: InstanceTopologyResponse;
 }
 
 /** A mounted ENDPOINT-DEPENDENCY view — the real per-endpoint API-dependency
@@ -223,7 +223,7 @@ export interface EndpointDependencySpec {
   /** Captured render-ready chain (endpoints + edges WITH metric values + edge
    *  series). The response's `endpointId` PINS which endpoint was drawn, so a
    *  reload replays the SAME chain — not the now-busiest endpoint. */
-  snapshot?: EndpointDependencyResponse;
+  replayData?: EndpointDependencyResponse;
 }
 
 /** A mounted TRACES view — the real native Traces view embedded read-only in
diff --git a/apps/ui/src/ai/AiFullPageView.vue 
b/apps/ui/src/ai/AiFullPageView.vue
index c513cd5..a12e62e 100644
--- a/apps/ui/src/ai/AiFullPageView.vue
+++ b/apps/ui/src/ai/AiFullPageView.vue
@@ -74,8 +74,13 @@ function clickClear(): void {
   confirmingClear.value = false;
   void conv.clearAll().then(refreshUsage);
 }
-function fmtMb(bytes: number): string {
-  return (bytes / (1024 * 1024)).toFixed(1);
+// Adaptive unit: real usage is KB-scale against a huge (500 MB) cap, so a
+// fixed "MB, 1 decimal" reads 0.0 forever. Show B / KB / MB so small usage — 
and
+// its growth — is actually visible.
+function fmtSize(bytes: number): string {
+  if (bytes < 1024) return `${Math.round(bytes)} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 
? 1 : 0)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 }
 // Refresh usage when a turn finishes — covers growth WITHIN a conversation, 
not
 // just a change in conversation count.
@@ -164,7 +169,7 @@ function when(ts: number): string {
             <div class="aifp__usage-bar">
               <div class="aifp__usage-fill" :style="{ width: Math.min(100, 
(usedBytes / maxBytes) * 100) + '%' }" />
             </div>
-            <span class="aifp__usage-txt">{{ fmtMb(usedBytes) }} / {{ 
fmtMb(maxBytes) }} MB</span>
+            <span class="aifp__usage-txt">{{ fmtSize(usedBytes) }} / {{ 
fmtSize(maxBytes) }}</span>
           </div>
           <div v-if="ordered.length > 0" class="aifp__clear">
             <button v-if="!confirmingClear" type="button" 
class="aifp__clear-btn" @click="clickClear">{{ t('Clear all') }}</button>
diff --git a/apps/ui/src/ai/ChatDeploymentBlock.vue 
b/apps/ui/src/ai/ChatDeploymentBlock.vue
index f113d66..e26bbc7 100644
--- a/apps/ui/src/ai/ChatDeploymentBlock.vue
+++ b/apps/ui/src/ai/ChatDeploymentBlock.vue
@@ -45,7 +45,8 @@ const captured = computed<string>(() =>
         :layer-key="spec.layer.toLowerCase()"
         :focus-service-id="spec.serviceId"
         :focus-window-minutes="spec.windowMinutes"
-        :snapshot="spec.snapshot"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue 
b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
index 3ead5c5..d37a0ba 100644
--- a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
+++ b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue
@@ -47,7 +47,8 @@ const captured = computed<string>(() =>
         :focus-service="spec.service"
         :focus-service-id="spec.serviceId"
         :focus-window-minutes="spec.windowMinutes"
-        :snapshot="spec.snapshot"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatHierarchyBlock.vue 
b/apps/ui/src/ai/ChatHierarchyBlock.vue
index 8599ac6..464394c 100644
--- a/apps/ui/src/ai/ChatHierarchyBlock.vue
+++ b/apps/ui/src/ai/ChatHierarchyBlock.vue
@@ -48,7 +48,8 @@ function noNodePos(): null {
       <ServiceHierarchyOverlay
         :standalone="true"
         :focus="{ serviceId: spec.serviceId, layer: spec.layer, serviceName: 
spec.service }"
-        :snapshot="spec.snapshot"
+        :replay="true"
+        :replay-data="spec.replayData"
         :view-box-w="0"
         :view-box-h="0"
         :resolve-node-pos="noNodePos"
diff --git a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue 
b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
index 45d4a0b..2bc06bb 100644
--- a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
+++ b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue
@@ -47,7 +47,8 @@ const captured = computed<string>(() =>
         :focus-client-service-id="spec.clientServiceId"
         :focus-server-service-id="spec.serverServiceId"
         :focus-window-minutes="spec.windowMinutes"
-        :snapshot="spec.snapshot"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatTopologyBlock.vue 
b/apps/ui/src/ai/ChatTopologyBlock.vue
index b4fed99..a679352 100644
--- a/apps/ui/src/ai/ChatTopologyBlock.vue
+++ b/apps/ui/src/ai/ChatTopologyBlock.vue
@@ -50,7 +50,8 @@ const captured = computed<string>(() =>
         :fit-scale="1.2"
         :zoom-controls="true"
         :focus-window-minutes="spec.windowMinutes"
-        :snapshot="spec.snapshot"
+        :replay="true"
+        :replay-data="spec.replayData"
       />
     </div>
   </div>
diff --git a/apps/ui/src/ai/ChatWidgetRenderer.vue 
b/apps/ui/src/ai/ChatWidgetRenderer.vue
index 330c920..954aba2 100644
--- a/apps/ui/src/ai/ChatWidgetRenderer.vue
+++ b/apps/ui/src/ai/ChatWidgetRenderer.vue
@@ -48,11 +48,37 @@ const cardText = computed<string>(() => {
 // Document the captured analysis: the metric's explanation (template tip) + 
the
 // exact MQE that produced this frozen result.
 const mqe = computed<string>(() => (spec.value.expressions ?? 
[]).filter(Boolean).join('  ·  '));
+
+// A captured figure is a static file of what was read — if the read had no 
value
+// (empty window or a failed read at capture), replay says so, the same frozen
+// no-value contract the map blocks follow. A real 0 is a value, not no-value.
+const noValue = computed<boolean>(() => {
+  const r = result.value;
+  if (r.error) return true;
+  switch (spec.value.type) {
+    case 'card':
+      return r.value == null;
+    case 'line':
+      return !r.series?.some((s) => s.data?.some((v) => v != null));
+    case 'top':
+      return !(r.topList?.length || r.topGroups?.length);
+    case 'table':
+      return !r.table?.length;
+    case 'record':
+      return !r.records?.length;
+    default:
+      return false;
+  }
+});
 </script>
 
 <template>
   <div class="cwr">
-    <div v-if="spec.type === 'card'" class="cwr-card">
+    <div v-if="noValue" class="cwr-empty">
+      {{ result.error ? 'No data — the read failed when this was captured.' : 
'No data in the captured window.' }}
+    </div>
+
+    <div v-else-if="spec.type === 'card'" class="cwr-card">
       <span class="cwr-card-val">{{ cardText }}</span>
       <span v-if="spec.unit" class="cwr-card-unit">{{ spec.unit }}</span>
     </div>
@@ -124,6 +150,12 @@ const mqe = computed<string>(() => (spec.value.expressions 
?? []).filter(Boolean
   color: var(--sw-fg-3);
   padding: 8px;
 }
+.cwr-empty {
+  font-size: var(--sw-fs-sm);
+  color: var(--sw-fg-3);
+  padding: 14px 8px;
+  text-align: center;
+}
 .cwr-meta {
   margin-top: 6px;
   padding-top: 6px;
diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts
index e146813..c17ea50 100644
--- a/apps/ui/src/ai/types.ts
+++ b/apps/ui/src/ai/types.ts
@@ -119,7 +119,7 @@ export interface HierarchySpec {
   errorReason?: string | null;
   /** Captured raw hierarchy (the overlay's native shape). Present ⇒ the 
embedded
    *  overlay seeds from it and never re-queries — a reloaded fan replays 
static. */
-  snapshot?: ServiceHierarchyResponse;
+  replayData?: ServiceHierarchyResponse;
 }
 
 // A focused one-hop ego topology drawn inline: the focus service + its DIRECT
@@ -147,7 +147,7 @@ export interface TopologySpec {
   /** Captured render-ready graph (nodes+edges WITH metric values + edge 
series).
    *  Present ⇒ the embedded map SEEDS from it and never re-queries OAP, so a
    *  reloaded conversation replays the exact map + edge part-graphs 
statically. */
-  snapshot?: TopologyResponse;
+  replayData?: TopologyResponse;
 }
 
 // A mounted Deployment view (real feature view, embedded read-only) focused 
on a
@@ -160,8 +160,8 @@ export interface DeploymentSpec {
   serviceId: string;
   windowMinutes?: number;
   /** Captured render-ready graph (instances + edges WITH values + edge 
series).
-   *  Present ⇒ seed the embedded view statically + replay the edge 
part-graphs. */
-  snapshot?: DeploymentResponse;
+   *  Present ⇒ replay statically + the edge part-graphs. */
+  replayData?: DeploymentResponse;
 }
 
 // A mounted instance-topology view (real feature view, embedded read-only) 
for a
@@ -176,8 +176,8 @@ export interface InstanceTopologySpec {
   serverServiceId: string;
   windowMinutes?: number;
   /** Captured render-ready pair map (instances + edges WITH values + edge 
series).
-   *  Present ⇒ seed statically + replay the edge part-graphs. */
-  snapshot?: InstanceTopologyResponse;
+   *  Present ⇒ replay statically + the edge part-graphs. */
+  replayData?: InstanceTopologyResponse;
 }
 
 // A mounted endpoint-dependency view (real feature view, embedded read-only)
@@ -192,7 +192,7 @@ export interface EndpointDependencySpec {
   /** Captured render-ready chain (endpoints + edges WITH values + edge 
series);
    *  its `endpointId` PINS which endpoint was drawn so a reload replays the 
SAME
    *  chain, not the now-busiest endpoint. */
-  snapshot?: EndpointDependencyResponse;
+  replayData?: EndpointDependencyResponse;
 }
 
 // A mounted native Traces view (real feature view, embedded read-only) focused
diff --git 
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue 
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 3be05a4..243c959 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -75,10 +75,11 @@ const props = defineProps<{
   /** Embedded look-back window (minutes); the query owns it and skips the 
global
    *  topbar picker + auto-refresh ticker, like the topology / deployment 
blocks. */
   focusWindowMinutes?: number;
-  /** Captured chain (AI chat replay). Present ⇒ the view SEEDS from it and 
never
-   *  fetches; the snapshot's endpointId PINS which endpoint is drawn, so a 
reload
-   *  replays the SAME chain (not the now-busiest endpoint) + its edge 
sparklines. */
-  snapshot?: EndpointDependencyResponse;
+  /** REPLAY mode (AI chat): render statically from `replayData`, never fetch; 
the
+   *  replayData's endpointId PINS which endpoint is drawn, so a reload 
replays the
+   *  SAME chain (not the now-busiest endpoint) + its edge sparklines. */
+  replay?: boolean;
+  replayData?: EndpointDependencyResponse;
 }>();
 
 const route = useRoute();
@@ -117,7 +118,9 @@ const namingRule = computed(() => layer.value?.naming ?? 
null);
 function identity(name: string | null | undefined): ServiceIdentity {
   return resolveServiceIdentity(name, namingRule.value);
 }
-const landing = useLayerLanding(safeLayer, safeCfg);
+// A replay map takes its service from props.focusService (not the landing 
rollup)
+// and hides the picker, so it fires ZERO landing queries — gated by replay 
mode.
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, computed(() => 
props.replay ?? false));
 const resolvedServiceName = useLayerServiceName(layerKey, landing);
 const serviceName = computed<string | null>(() =>
   embedded.value ? (props.focusService ?? null) : resolvedServiceName.value,
@@ -151,6 +154,8 @@ const { endpoints: endpointList, isFetching: 
endpointsLoading } = useLayerEndpoi
   serviceName,
   endpointQuery,
   endpointLimit,
+  // A replay map pins its endpoint from replayData + hides the picker — no 
fetch.
+  computed(() => props.replay ?? false),
 );
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedEndpoint.value) {
@@ -161,13 +166,13 @@ watch(serviceName, (next, prev) => {
 // pick). The `return` after step 1 prevents racing the endpoint pick
 // before `serviceName` has propagated and the endpoint list refreshed.
 watchEffect(() => {
-  // A seeded (replayed) chain PINS its endpoint: seed the local pick from the
-  // snapshot's endpointId (its focused node) so the graph card renders — the
-  // template gates on a non-null selectedEndpoint — and never auto-pick over 
it,
-  // or a reload would redraw a different (now-busier) endpoint.
-  if (props.snapshot) {
+  // A REPLAY chain PINS its endpoint: seed the local pick from the 
replayData's
+  // endpointId (its focused node) so the graph card renders — the template 
gates
+  // on a non-null selectedEndpoint — and never auto-pick over it, or a reload
+  // would redraw a different (now-busier) endpoint.
+  if (props.replay) {
     if (!selectedEndpoint.value) {
-      const pinned = props.snapshot.nodes.find((n) => n.id === 
props.snapshot!.endpointId)?.name;
+      const pinned = props.replayData?.nodes.find((n) => n.id === 
props.replayData?.endpointId)?.name;
       if (pinned) setSelectedEndpoint(pinned);
     }
     return;
@@ -193,13 +198,13 @@ watchEffect(() => {
 const focusWindowMinutes = computed<number | null>(() =>
   embedded.value ? (props.focusWindowMinutes ?? 60) : null,
 );
-const endpointSeed = computed<EndpointDependencyResponse | null>(() => 
props.snapshot ?? null);
+const replayDataRef = computed<EndpointDependencyResponse | null>(() => 
props.replayData ?? null);
 const { nodes: baseNodes, calls: baseCalls, isLoading, isFetching, data } = 
useLayerEndpointDependency(
   layerKey,
   serviceName,
   selectedEndpoint,
   focusWindowMinutes,
-  endpointSeed,
+  replayDataRef,
 );
 const reachable = computed(() => data.value?.reachable !== false);
 const errorText = computed(() => data.value?.error ?? null);
@@ -984,7 +989,7 @@ function edgeRowCrosshair(rowId: string): number | null {
                  (loading callers & callees) → `+` accent (expanded) or a
                  faded `·` (no further dependency). -->
             <g
-              v-if="selectedNodeId === n.id && n.id !== focusedId"
+              v-if="selectedNodeId === n.id && n.id !== focusedId && !embedded"
               class="ep-expand"
               :class="{ exhausted: isExhausted(n), loading: 
isLoadingExpansion(n) }"
               :transform="`translate(${NW - 9}, -9)`"
@@ -1183,7 +1188,8 @@ function edgeRowCrosshair(rowId: string): number | null {
     </section>
 
     <section v-else-if="serviceName" class="empty">
-      Select an endpoint above to see its dependency chain.
+      <template v-if="embedded">No API-dependency data was captured for {{ 
serviceName }} in this window.</template>
+      <template v-else>Select an endpoint above to see its dependency 
chain.</template>
     </section>
   </div>
 </template>
diff --git 
a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts 
b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 886a65d..20b4ed2 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -37,10 +37,11 @@ export function useLayerEndpointDependency(
    *  OWN frozen look-back window and does NOT follow the global topbar picker 
or
    *  auto-refresh ticker — the interactive route omits it. */
   windowMinutes?: Ref<number | null>,
-  /** Captured chain to SEED from (AI chat static replay). Present ⇒ start 
with it
-   *  and NEVER fetch, so a reload replays the SAME pinned endpoint chain 
offline. */
-  seed?: Ref<EndpointDependencyResponse | null>,
+  /** REPLAY mode: the captured chain to render from. Present ⇒ start with it 
and
+   *  NEVER fetch, so a reload replays the SAME pinned endpoint chain offline. 
*/
+  replayData?: Ref<EndpointDependencyResponse | null>,
 ) {
+  const replay = computed(() => !!replayData?.value);
   const ownsWindow = (windowMinutes?.value ?? 0) > 0;
   // Preview-only: forward the draft `endpointDependency` block.
   const previewCfg = usePreviewLayerBlock(layerKey, 'endpointDependency');
@@ -68,12 +69,12 @@ export function useLayerEndpointDependency(
         previewCfg.value,
       ),
     enabled: computed(
-      () => layerKey.value.length > 0 && !!service.value && !!endpoint.value 
&& !seed?.value,
+      () => layerKey.value.length > 0 && !!service.value && !!endpoint.value 
&& !replay.value,
     ),
-    initialData: () => seed?.value ?? undefined,
+    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
-  if (!ownsWindow && !seed?.value) useAutoRefreshSubscribe(() => q.refetch());
+  if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch());
 
   return {
     data: computed(() => q.data.value ?? null),
diff --git a/apps/ui/src/layer/service-map/LayerDeploymentView.vue 
b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
index ad61e92..c872de7 100644
--- a/apps/ui/src/layer/service-map/LayerDeploymentView.vue
+++ b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
@@ -79,9 +79,10 @@ const props = defineProps<{
   /** Embedded look-back window (minutes); the query owns it and skips the 
global
    *  topbar picker + auto-refresh ticker, like the topology block. */
   focusWindowMinutes?: number;
-  /** Captured graph (AI chat replay). Present ⇒ the view SEEDS from it and 
never
-   *  fetches, so a reload replays the exact graph + edge part-graphs offline. 
*/
-  snapshot?: DeploymentResponse;
+  /** REPLAY mode (AI chat): render statically from `replayData`, never fetch 
— a
+   *  reload replays the exact graph + edge part-graphs offline. */
+  replay?: boolean;
+  replayData?: DeploymentResponse;
 }>();
 
 const route = useRoute();
@@ -116,8 +117,8 @@ const enabled = computed(() => !!selectedId.value);
 const focusWindowMinutes = computed<number | null>(() =>
   embedded.value ? (props.focusWindowMinutes ?? 60) : null,
 );
-const deploymentSeed = computed<DeploymentResponse | null>(() => 
props.snapshot ?? null);
-const { data, nodes, calls, isFetching } = useDeployment(layerKey, selectedId, 
enabled, focusWindowMinutes, deploymentSeed);
+const replayDataRef = computed<DeploymentResponse | null>(() => 
props.replayData ?? null);
+const { data, nodes, calls, isFetching } = useDeployment(layerKey, selectedId, 
enabled, focusWindowMinutes, replayDataRef);
 const serviceName = computed(() => displayServiceName(data.value?.serviceName) 
|| '');
 const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
 
diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
index 10fa7ed..095a9bf 100644
--- a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
@@ -59,9 +59,10 @@ const props = defineProps<{
   /** Embedded look-back window (minutes); the query owns it and skips the 
global
    *  topbar picker + auto-refresh ticker, like the topology / deployment 
blocks. */
   focusWindowMinutes?: number;
-  /** Captured pair map (AI chat replay). Present ⇒ the view SEEDS from it and
-   *  never fetches, so a reload replays the exact map + edge part-graphs 
offline. */
-  snapshot?: InstanceTopologyResponse;
+  /** REPLAY mode (AI chat): render statically from `replayData`, never fetch 
— a
+   *  reload replays the exact map + edge part-graphs offline. */
+  replay?: boolean;
+  replayData?: InstanceTopologyResponse;
 }>();
 
 const route = useRoute();
@@ -113,13 +114,15 @@ function displayName(name: string | null | undefined): 
string {
 // fallback on a selection the graph hasn't loaded yet.
 // Embedded blocks own a frozen window, so the roster (name fallback for the
 // pickers) must not ride the global ticker either.
-const roster = useLayerServices(layerKey, { rideTicker: !props.embedded });
+// A replay map uses replayData node names + hides the pickers, so the roster
+// (name fallback for the pickers) fires ZERO queries — gated by replay mode.
+const roster = useLayerServices(layerKey, { rideTicker: !props.embedded, 
replay: computed(() => props.replay ?? false) });
 const topoFocus = ref<string | null>(null);
 const topoDepth = ref(2);
 // A seeded (replayed) block is static — suppress the layer-wide topology fetch
 // that only feeds the interactive swap pickers, so a reload stays offline (it
 // would otherwise fire a fresh-Date.now()-window request). Empty key ⇒ 
disabled.
-const pickerLayerKey = computed(() => (props.snapshot ? '' : layerKey.value));
+const pickerLayerKey = computed(() => (props.replay ? '' : layerKey.value));
 const { nodes: topoNodes, calls: topoCalls } = useLayerTopology(
   pickerLayerKey,
   topoFocus,
@@ -209,14 +212,14 @@ function pick(which: 'client' | 'server', val: string): 
void {
 }
 
 const enabled = computed(() => !!clientId.value && !!serverId.value);
-const instanceSeed = computed<InstanceTopologyResponse | null>(() => 
props.snapshot ?? null);
+const replayDataRef = computed<InstanceTopologyResponse | null>(() => 
props.replayData ?? null);
 const { data, nodes, calls, isFetching } = useInstanceTopology(
   layerKey,
   clientId,
   serverId,
   enabled,
   focusWindowMinutes,
-  instanceSeed,
+  replayDataRef,
 );
 const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
 
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue 
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index c946c7f..bfe0717 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -112,12 +112,13 @@ const props = defineProps<{
    *  owns this window and ignores the global topbar picker + auto-refresh
    *  ticker — the chat block owns its time like the traces/logs blocks. */
   focusWindowMinutes?: number;
-  /** Captured graph to render statically (AI chat replay). When present the 
map
-   *  SEEDS from it (no OAP fetch, frozen point-in-time) AND the embedded 
detail
-   *  panel is re-enabled so a click shows the captured node/edge metrics + the
-   *  edge part-graph sparklines. The overview widget passes embedded WITHOUT a
-   *  snapshot, so it stays non-interactive. */
-  snapshot?: TopologyResponse;
+  /** REPLAY mode (AI chat): render statically from `replayData` — no OAP 
fetch,
+   *  frozen point-in-time — AND re-enable the embedded detail panel so a click
+   *  shows the captured node/edge metrics + edge part-graph sparklines. The
+   *  overview widget is embedded but NOT replay, so it stays non-interactive. 
*/
+  replay?: boolean;
+  /** The captured graph to render in replay mode. */
+  replayData?: TopologyResponse;
 }>();
 const route = useRoute();
 const router = useRouter();
@@ -152,7 +153,9 @@ const safeCfg = computed(() => {
     slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics,
   }).landing;
 });
-const landing = useLayerLanding(safeLayer, safeCfg);
+// A replay map hides the service picker this rollup feeds, so it fires no 
landing
+// fetch and no ticker — gated by replay mode.
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, computed(() => 
props.replay ?? false));
 const landingRows = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
 
 // Focus-service is local to the topology view (NOT the header's
@@ -194,13 +197,13 @@ function truncateLabel(s: string, n: number): string {
 
 const depth = ref<number>(props.focusDepth ?? 2);
 const focusWindowMinutes = computed<number | null>(() => 
props.focusWindowMinutes ?? null);
-const topologySeed = computed<TopologyResponse | null>(() => props.snapshot ?? 
null);
+const replayDataRef = computed<TopologyResponse | null>(() => props.replayData 
?? null);
 const { nodes, calls, isLoading, isFetching, data, refetch } = 
useLayerTopology(
   layerKey,
   serviceName,
   depth,
   focusWindowMinutes,
-  topologySeed,
+  replayDataRef,
 );
 const reachable = computed(() => data.value?.reachable !== false);
 const errorText = computed(() => data.value?.error ?? null);
@@ -582,14 +585,14 @@ function edgeMidpoint(c: TopologyCall): { x: number; y: 
number } | null {
 const selectedNodeId = ref<string | null>(null);
 const selectedCallId = ref<string | null>(null);
 function selectNode(id: string | null): void {
-  // The overview widget (embedded, no snapshot) renders a static map with no
-  // detail sidebar — click is a no-op. A seeded chat replay (embedded WITH a
-  // snapshot) re-enables click so the captured node/edge detail can open.
-  if (embedded.value && !props.snapshot) return;
+  // The overview widget (embedded, not replay) renders a static map with no
+  // detail sidebar — click is a no-op. A chat REPLAY (embedded WITH replay)
+  // re-enables click so the captured node/edge detail can open.
+  if (embedded.value && !props.replay) return;
   selectedNodeId.value = selectedNodeId.value === id ? null : id;
 }
 function selectCall(id: string | null): void {
-  if (embedded.value && !props.snapshot) return;
+  if (embedded.value && !props.replay) return;
   selectedCallId.value = selectedCallId.value === id ? null : id;
 }
 // Escape closes whichever detail panel (node or edge) is open.
@@ -1217,7 +1220,7 @@ onBeforeUnmount(() => {
           All nodes are hidden by the current filter.
           <button class="sw-btn small" type="button" 
@click="resetFilter">Reset filter</button>
         </div>
-        <div v-else-if="!tooLarge" class="loader">
+        <div v-else-if="!tooLarge && reachable" class="loader">
           No services with metric data in this layer for the last 15 minutes.
         </div>
 
diff --git a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue 
b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
index fb7e837..51fa24d 100644
--- a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
+++ b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
@@ -68,9 +68,10 @@ const props = defineProps<{
    *  guarded on this, so the interactive topology overlay is unchanged. */
   standalone?: boolean;
   focus?: { serviceId: string; layer: string; serviceName: string };
-  /** Captured hierarchy (AI chat replay, standalone only). Present ⇒ the 
overlay
-   *  SEEDS from it and never re-queries, so a reloaded fan replays 
statically. */
-  snapshot?: ServiceHierarchyResponse;
+  /** REPLAY mode (AI chat, standalone only): render statically from 
`replayData`,
+   *  never re-query — a reloaded fan replays statically. */
+  replay?: boolean;
+  replayData?: ServiceHierarchyResponse;
 }>();
 
 const store = useHierarchyOverlayStore();
@@ -86,8 +87,8 @@ const isOpen = computed<boolean>(() => (props.standalone ? 
true : store.isOpen))
 
 const layerKey = computed(() => (fLayer.value ?? '').toLowerCase());
 const focusServiceId = computed(() => fServiceId.value);
-const hierarchySeed = computed<ServiceHierarchyResponse | null>(() => 
props.snapshot ?? null);
-const { data, isLoading } = useServiceHierarchy(layerKey, focusServiceId, 
hierarchySeed);
+const replayDataRef = computed<ServiceHierarchyResponse | null>(() => 
props.replayData ?? null);
+const { data, isLoading } = useServiceHierarchy(layerKey, focusServiceId, 
replayDataRef);
 
 /** Look up a layer's color from the menu registry; fall back to the
  *  accent for layers we don't have an entry for (e.g. SO11Y_OAP). */
diff --git a/apps/ui/src/layer/service-map/useDeployment.ts 
b/apps/ui/src/layer/service-map/useDeployment.ts
index 95de296..4089035 100644
--- a/apps/ui/src/layer/service-map/useDeployment.ts
+++ b/apps/ui/src/layer/service-map/useDeployment.ts
@@ -40,10 +40,11 @@ export function useDeployment(
    *  OWN frozen look-back window and does NOT follow the global topbar picker 
or
    *  auto-refresh ticker — the interactive route omits it. */
   windowMinutes?: Ref<number | null>,
-  /** Captured graph to SEED from (AI chat static replay). Present ⇒ start 
with it
-   *  and NEVER fetch, so a reload replays the exact graph + edge series 
offline. */
-  seed?: Ref<DeploymentResponse | null>,
+  /** REPLAY mode: the captured graph to render from. Present ⇒ start with it 
and
+   *  NEVER fetch, so a reload replays the exact graph + edge series offline. 
*/
+  replayData?: Ref<DeploymentResponse | null>,
 ) {
+  const replay = computed(() => !!replayData?.value);
   const ownsWindow = (windowMinutes?.value ?? 0) > 0;
   const timeRange = useTimeRangeStore();
   // Preview-only: the draft top-level `deployment` block, so
@@ -62,7 +63,7 @@ export function useDeployment(
     };
   });
   const isEnabled = computed(
-    () => enabled.value && layerKey.value.length > 0 && !!serviceId.value && 
!seed?.value,
+    () => enabled.value && layerKey.value.length > 0 && !!serviceId.value && 
!replay.value,
   );
   const q = useQuery({
     queryKey: ['layer-deployment', layerKey, serviceId, rangeKey, previewCfg],
@@ -74,13 +75,13 @@ export function useDeployment(
         previewCfg.value,
       ),
     enabled: isEnabled,
-    initialData: () => seed?.value ?? undefined,
+    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // Only ride the global ticker while the view is active — a forced refetch
   // on a closed/disabled query would fetch needlessly. The embedded chat map
-  // owns a frozen window, and a seeded (replayed) map never fetches at all.
-  if (!ownsWindow && !seed?.value) {
+  // owns a frozen window, and a replay map never fetches at all.
+  if (!ownsWindow && !replay.value) {
     useAutoRefreshSubscribe(() => {
       if (isEnabled.value) void q.refetch();
     });
diff --git a/apps/ui/src/layer/service-map/useInstanceTopology.ts 
b/apps/ui/src/layer/service-map/useInstanceTopology.ts
index fd80ecc..382621a 100644
--- a/apps/ui/src/layer/service-map/useInstanceTopology.ts
+++ b/apps/ui/src/layer/service-map/useInstanceTopology.ts
@@ -42,10 +42,11 @@ export function useInstanceTopology(
    *  OWN frozen look-back window and does NOT follow the global topbar picker 
or
    *  auto-refresh ticker — the interactive route omits it. */
   windowMinutes?: Ref<number | null>,
-  /** Captured graph to SEED from (AI chat static replay). Present ⇒ start 
with it
-   *  and NEVER fetch, so a reload replays the exact map + edge series 
offline. */
-  seed?: Ref<InstanceTopologyResponse | null>,
+  /** REPLAY mode: the captured graph to render from. Present ⇒ start with it 
and
+   *  NEVER fetch, so a reload replays the exact map + edge series offline. */
+  replayData?: Ref<InstanceTopologyResponse | null>,
 ) {
+  const replay = computed(() => !!replayData?.value);
   const ownsWindow = (windowMinutes?.value ?? 0) > 0;
   const timeRange = useTimeRangeStore();
   // Preview-only: the draft `topology` block (the BFF reads its nested
@@ -69,7 +70,7 @@ export function useInstanceTopology(
       layerKey.value.length > 0 &&
       !!clientServiceId.value &&
       !!serverServiceId.value &&
-      !seed?.value,
+      !replay.value,
   );
   const q = useQuery({
     queryKey: ['layer-instance-topology', layerKey, clientServiceId, 
serverServiceId, rangeKey, previewCfg],
@@ -82,13 +83,13 @@ export function useInstanceTopology(
         previewCfg.value,
       ),
     enabled: isEnabled,
-    initialData: () => seed?.value ?? undefined,
+    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // Only ride the global ticker while the view is active — a forced
   // refetch on a closed/disabled query would fetch needlessly. The embedded
-  // chat map owns a frozen window, and a seeded map never fetches at all.
-  if (!ownsWindow && !seed?.value) {
+  // chat map owns a frozen window, and a replay map never fetches at all.
+  if (!ownsWindow && !replay.value) {
     useAutoRefreshSubscribe(() => {
       if (isEnabled.value) void q.refetch();
     });
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts 
b/apps/ui/src/layer/service-map/useLayerTopology.ts
index 7a78dc8..df4f3b0 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useLayerTopology.ts
@@ -41,12 +41,14 @@ export function useLayerTopology(
    *  OWN window (a frozen look-back snapshot) and does NOT follow the global
    *  topbar picker or auto-refresh ticker — the interactive route omits it. */
   windowMinutes?: Ref<number | null>,
-  /** Captured graph to SEED from (AI chat static replay). When present the 
query
+  /** REPLAY mode: the captured graph to render from. When present the query
    *  starts with it and NEVER fetches — a reloaded conversation replays the 
exact
-   *  snapshot (nodes+edges+series) with zero OAP round-trip, so it can't 
slide to
-   *  a fresh window and survives an offline OAP. */
-  seed?: Ref<TopologyResponse | null>,
+   *  data (nodes+edges+series) with zero OAP round-trip, so it can't slide to 
a
+   *  fresh window and survives an offline OAP. */
+  replayData?: Ref<TopologyResponse | null>,
 ) {
+  // replay mode is on whenever captured data is supplied.
+  const replay = computed(() => !!replayData?.value);
   const ownsWindow = (windowMinutes?.value ?? 0) > 0;
   const timeRange = useTimeRangeStore();
   // In `?mode=preview` only: forward the operator's draft `topology` block
@@ -81,15 +83,15 @@ export function useLayerTopology(
         rangeKey.value,
         previewCfg.value,
       ),
-    // A seeded map is static: start with the captured graph and never fetch.
-    enabled: computed(() => layerKey.value.length > 0 && !seed?.value),
-    initialData: () => seed?.value ?? undefined,
+    // Replay is static: start with the captured graph and never fetch.
+    enabled: computed(() => layerKey.value.length > 0 && !replay.value),
+    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // The embedded chat map owns its own frozen window, so it must NOT refetch 
on
-  // the global ticker — only the interactive route subscribes. A seeded 
(replayed)
-  // map never fetches either.
-  if (!ownsWindow && !seed?.value) useAutoRefreshSubscribe(() => q.refetch());
+  // the global ticker — only the interactive route subscribes. A replay map 
never
+  // fetches either.
+  if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch());
 
   return {
     data: computed(() => q.data.value ?? null),
diff --git a/apps/ui/src/layer/service-map/useServiceHierarchy.ts 
b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
index 84a52d4..23d05cf 100644
--- a/apps/ui/src/layer/service-map/useServiceHierarchy.ts
+++ b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
@@ -37,16 +37,17 @@ import { bffClient } from '@/api/client';
 export function useServiceHierarchy(
   layerKey: Ref<string>,
   serviceId: Ref<string | null>,
-  /** Captured hierarchy to SEED from (AI chat static replay). Present ⇒ start
-   *  with it and NEVER fetch, so a reloaded fan replays statically. */
-  seed?: Ref<ServiceHierarchyResponse | null>,
+  /** REPLAY mode: the captured hierarchy to render from. Present ⇒ start with 
it
+   *  and NEVER fetch, so a reloaded fan replays statically. */
+  replayData?: Ref<ServiceHierarchyResponse | null>,
 ) {
+  const replay = computed(() => !!replayData?.value);
   const q = useQuery({
     queryKey: ['layer-service-hierarchy', layerKey, serviceId],
     queryFn: (): Promise<ServiceHierarchyResponse> =>
       bffClient.layer.serviceHierarchy(layerKey.value, serviceId.value!),
-    enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value && 
!seed?.value),
-    initialData: () => seed?.value ?? undefined,
+    enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value && 
!replay.value),
+    initialData: () => replayData?.value ?? undefined,
     staleTime: 60_000,
   });
 
diff --git a/apps/ui/src/layer/useLayerEndpoints.ts 
b/apps/ui/src/layer/useLayerEndpoints.ts
index 6315c4c..42311cc 100644
--- a/apps/ui/src/layer/useLayerEndpoints.ts
+++ b/apps/ui/src/layer/useLayerEndpoints.ts
@@ -32,12 +32,16 @@ export function useLayerEndpoints(
   service: Ref<string | null>,
   query: Ref<string>,
   limit: Ref<number>,
+  /** REPLAY mode gate: a replay endpoint-dependency map pins its endpoint 
from the
+   *  replayData and hides the picker this list feeds, so it passes a true ref 
to
+   *  fire ZERO queries. Defaults off (live) for the interactive route. */
+  replay?: Ref<boolean>,
 ) {
   const q = useQuery({
     queryKey: ['layer-endpoints', layerKey, service, query, limit],
     queryFn: () =>
       bffClient.layer.endpoints(layerKey.value, service.value ?? '', 
query.value, limit.value),
-    enabled: computed(() => layerKey.value.length > 0 && !!service.value),
+    enabled: computed(() => !(replay?.value ?? false) && layerKey.value.length 
> 0 && !!service.value),
     staleTime: 30_000,
   });
   return {
diff --git a/apps/ui/src/layer/useLayerLanding.ts 
b/apps/ui/src/layer/useLayerLanding.ts
index 16ccca7..e2debc2 100644
--- a/apps/ui/src/layer/useLayerLanding.ts
+++ b/apps/ui/src/layer/useLayerLanding.ts
@@ -43,6 +43,10 @@ export function useLayerLanding(
    *  + queryKey so a time-picker change refires the landing
    *  rollup the same way a layer change does. */
   range?: Ref<LandingRange | null>,
+  /** REPLAY mode gate: a replay (captured) map hides the service picker this
+   *  rollup feeds and must fire ZERO queries, so it passes a true ref to 
suppress
+   *  the fetch. Defaults off (live) for the interactive route. */
+  replay?: Ref<boolean>,
 ) {
   const layerKey = computed(() => layer.value.key);
   // Cache key reflects every field that changes the server response —
@@ -68,6 +72,7 @@ export function useLayerLanding(
   //   2. The manual refresh button in LayerShell — `q.refetch()`.
   // No silent vue-query-driven refetch under the operator, so the
   // service list never moves on its own between operator actions.
+  const isEnabled = computed(() => !(replay?.value ?? false));
   const q = useQuery({
     queryKey: ['layer-landing', layerKey, cfgHash, rangeKey],
     queryFn: () =>
@@ -76,12 +81,15 @@ export function useLayerLanding(
         cfg.value,
         rangeRef.value ?? undefined,
       ),
+    enabled: isEnabled,
     staleTime: Infinity,
     refetchOnWindowFocus: false,
     retry: 1,
   });
 
-  useAutoRefreshSubscribe(() => q.refetch());
+  useAutoRefreshSubscribe(() => {
+    if (isEnabled.value) void q.refetch();
+  });
 
   const data = computed<LandingResponse | null>(() => q.data.value ?? null);
   const rows = computed(() => data.value?.rows ?? []);
diff --git a/apps/ui/src/layer/useLayerServices.ts 
b/apps/ui/src/layer/useLayerServices.ts
index 155eb34..4f0313b 100644
--- a/apps/ui/src/layer/useLayerServices.ts
+++ b/apps/ui/src/layer/useLayerServices.ts
@@ -53,18 +53,21 @@ export function useLayerServices(
   layerKey: Ref<string>,
   /** Embedded (chat) callers pass `{ rideTicker: false }` so the roster does 
not
    *  subscribe to the global auto-refresh ticker — an embedded block owns its 
own
-   *  frozen window and must not add a ticker-driven refetch. Default rides 
it. */
-  opts: { rideTicker?: boolean } = {},
+   *  frozen window and must not add a ticker-driven refetch. Default rides it.
+   *  `replay: true` suppresses the fetch entirely — a replay map hides the 
pickers
+   *  this roster feeds and must fire ZERO queries. */
+  opts: { rideTicker?: boolean; replay?: Ref<boolean> } = {},
 ) {
+  const isEnabled = computed(() => !(opts.replay?.value ?? false) && 
layerKey.value.length > 0);
   const q = useQuery({
     queryKey: ['layer-services', layerKey],
     queryFn: () => bffClient.layer.services(layerKey.value),
-    enabled: computed(() => layerKey.value.length > 0),
+    enabled: isEnabled,
     staleTime: 60_000,
   });
   if (opts.rideTicker !== false) {
     useAutoRefreshSubscribe(() => {
-      if (layerKey.value.length > 0) void q.refetch();
+      if (isEnabled.value) void q.refetch();
     });
   }
   return {

Reply via email to