@agenthippo-ai/agenthippo 0.1.1 → 0.2.3-dataplane

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -9
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/server.js +36 -0
  11. package/out/cli/commands/ask.js +3 -0
  12. package/out/cli/commands/chat.js +3 -0
  13. package/out/cli/commands/deploy.js +49 -10
  14. package/out/cli/commands/gateway.js +54 -0
  15. package/out/cli/commands/idp.js +85 -0
  16. package/out/cli/commands/serve.js +315 -55
  17. package/out/cli/credential-context.js +42 -80
  18. package/out/cli/data-plane/broker.js +78 -0
  19. package/out/cli/data-plane/connector-host.js +121 -0
  20. package/out/cli/data-plane/egress.js +189 -0
  21. package/out/cli/data-plane/gateway.js +195 -0
  22. package/out/cli/data-plane/index.js +137 -0
  23. package/out/cli/data-plane/registry.js +80 -0
  24. package/out/cli/deploy/blueprints/registry.js +84 -0
  25. package/out/cli/deploy/layers/identity.js +91 -0
  26. package/out/cli/deploy-init.js +1105 -335
  27. package/out/cli/idp-init.js +303 -0
  28. package/out/cli/index.js +37 -7
  29. package/out/cli/pack-loader.js +37 -2
  30. package/out/cli/runtime.js +10 -3
  31. package/out/cli/serve-hardening.js +60 -1
  32. package/out/cli/state-sync.js +16 -15
  33. package/out/engines/claude.js +26 -5
  34. package/out/engines/codex.js +24 -5
  35. package/out/engines/custom.js +46 -12
  36. package/out/extension.js +2 -0
  37. package/out/gateway/outboundSend.js +6 -6
  38. package/out/headless/index.js +24 -9
  39. package/out/live-probe/connector.mjs +8 -0
  40. package/out/live-probe/env-report.mjs +8 -0
  41. package/out/mcp.js +85 -67
  42. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  43. package/out/shared/models.js +1 -1
  44. package/out/shared/providers.js +2 -2
  45. package/out/test/analytics.test.js +42 -17
  46. package/out/test/auth-broker.test.js +53 -0
  47. package/out/test/blueprint-registry.test.js +105 -0
  48. package/out/test/credential-context.test.js +23 -28
  49. package/out/test/custom-env-layering.test.js +151 -0
  50. package/out/test/data-plane-engine-wiring.test.js +250 -0
  51. package/out/test/data-plane.test.js +244 -0
  52. package/out/test/deploy-init.test.js +255 -4
  53. package/out/test/egress-broker.test.js +172 -0
  54. package/out/test/fleetDisplayUtils.test.js +4 -0
  55. package/out/test/idp-init.test.js +151 -0
  56. package/out/test/mcp-obo.test.js +52 -27
  57. package/out/test/mcp.test.js +43 -3
  58. package/out/test/model-routing.test.js +133 -0
  59. package/out/test/serve-gateway-turn.test.js +267 -0
  60. package/out/test/serve-hardening.test.js +80 -0
  61. package/out/test/serveClient.test.js +32 -0
  62. package/out/test/state-sync-peer.test.js +18 -5
  63. package/out/utils.js +27 -11
  64. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
4
  *--------------------------------------------------------------------------------------------*/
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.checkGitHubUpdate = exports.installFromGitHub = exports.validateForPublish = exports.publishToGitHub = exports.getGitHubUser = exports.isGitHubAuthenticated = exports.getGitHubToken = exports.formatGitHubSource = exports.parseGitHubSource = exports.resetDefaultAgent = exports.duplicateAgent = exports.updateFromUserLibrary = exports.checkForUpdate = exports.readAgentSource = exports.promoteToUserLibrary = exports.hydrateFromUserLibrary = exports.isAgentInUserLibrary = exports.isWorkspaceAgentAvailable = exports.listUserLibraryAgents = exports.updateManifestName = exports.parseAgentManifest = exports.loadAgentManifest = exports.stringifyYaml = exports.parseYaml = exports.AgentPackManager = exports.getUserAgentCurrentPath = exports.getUserAgentVersionDir = exports.getUserAgentRoot = exports.getUserAgentsDir = exports.getAgentideHome = exports.parseAgentIdFromPath = exports.getSessionsDir = exports.getAgentMcpDir = exports.getAgentRulesDir = exports.getAgentSkillsDir = exports.getAgentContextPath = exports.getAgentAgentsMdPath = exports.getAgentManifestPath = exports.isDefaultAgent = exports.resolveRuntimePaths = exports.resolveAgentRuntimePaths = exports.getAgentRoot = exports.getAgentsDir = exports.getAgentideDir = exports.AGENT_CONSTANTS = void 0;
6
+ exports.checkGitHubUpdate = exports.installFromGitHub = exports.validateForPublish = exports.publishToGitHub = exports.getGitHubUser = exports.isGitHubAuthenticated = exports.getGitHubToken = exports.formatGitHubSource = exports.parseGitHubSource = exports.resetDefaultAgent = exports.duplicateAgent = exports.updateFromUserLibrary = exports.checkForUpdate = exports.readAgentSource = exports.promoteToUserLibrary = exports.hydrateFromUserLibrary = exports.isAgentInUserLibrary = exports.isWorkspaceAgentAvailable = exports.listUserLibraryAgents = exports.updateManifestName = exports.parseAgentManifest = exports.loadAgentManifest = exports.stringifyYaml = exports.parseYaml = exports.AgentPackManager = exports.getUserAgentCurrentPath = exports.getUserAgentVersionDir = exports.getUserAgentRoot = exports.getUserAgentsDir = exports.getAgentideHome = exports.parseAgentIdFromPath = exports.getSessionsDir = exports.getAgentMcpDir = exports.getAgentRulesDir = exports.getAgentSkillsDir = exports.getAgentContextPath = exports.getAgentAgentsMdPath = exports.getAgentManifestPath = exports.isDefaultAgent = exports.userScopedEngineHome = exports.resolveRuntimePaths = exports.resolveAgentRuntimePaths = exports.getAgentRoot = exports.getAgentsDir = exports.getAgentideDir = exports.AGENT_CONSTANTS = void 0;
7
7
  // Path utilities
8
8
  var paths_1 = require("./paths");
9
9
  Object.defineProperty(exports, "AGENT_CONSTANTS", { enumerable: true, get: function () { return paths_1.AGENT_CONSTANTS; } });
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "getAgentsDir", { enumerable: true, get: function
12
12
  Object.defineProperty(exports, "getAgentRoot", { enumerable: true, get: function () { return paths_1.getAgentRoot; } });
13
13
  Object.defineProperty(exports, "resolveAgentRuntimePaths", { enumerable: true, get: function () { return paths_1.resolveAgentRuntimePaths; } });
14
14
  Object.defineProperty(exports, "resolveRuntimePaths", { enumerable: true, get: function () { return paths_1.resolveRuntimePaths; } });
15
+ Object.defineProperty(exports, "userScopedEngineHome", { enumerable: true, get: function () { return paths_1.userScopedEngineHome; } });
15
16
  Object.defineProperty(exports, "isDefaultAgent", { enumerable: true, get: function () { return paths_1.isDefaultAgent; } });
16
17
  Object.defineProperty(exports, "getAgentManifestPath", { enumerable: true, get: function () { return paths_1.getAgentManifestPath; } });
17
18
  Object.defineProperty(exports, "getAgentAgentsMdPath", { enumerable: true, get: function () { return paths_1.getAgentAgentsMdPath; } });
@@ -51,6 +51,7 @@ exports.getAgentsDir = getAgentsDir;
51
51
  exports.getAgentRoot = getAgentRoot;
52
52
  exports.resolveAgentRuntimePaths = resolveAgentRuntimePaths;
53
53
  exports.resolveRuntimePaths = resolveRuntimePaths;
54
+ exports.userScopedEngineHome = userScopedEngineHome;
54
55
  exports.isDefaultAgent = isDefaultAgent;
55
56
  exports.getAgentManifestPath = getAgentManifestPath;
56
57
  exports.getAgentAgentsMdPath = getAgentAgentsMdPath;
@@ -70,6 +71,7 @@ exports.generateAgentYaml = generateAgentYaml;
70
71
  exports.generateAgentsMd = generateAgentsMd;
71
72
  const path = __importStar(require("path"));
72
73
  const os = __importStar(require("os"));
74
+ const crypto = __importStar(require("crypto"));
73
75
  const branding_1 = require("../branding");
74
76
  /**
75
77
  * Constants for agent directory structure.
@@ -211,6 +213,27 @@ function resolveRuntimePaths(workspaceRoot, storageUri, agentId, agentVersion) {
211
213
  const effectiveAgentId = agentId ?? exports.AGENT_CONSTANTS.DEFAULT_AGENT_ID;
212
214
  return resolveAgentRuntimePaths(workspaceRoot, storageUri, effectiveAgentId, agentVersion);
213
215
  }
216
+ /**
217
+ * Key an engine's SDK state dir (CODEX_HOME / claudeConfigDir) by the verified request
218
+ * user in multi-tenant serve/headless deployments (secure-obo-design §8 Phase 0).
219
+ *
220
+ * The base dir is per-agent, not per-user — under concurrent turns of different users the
221
+ * codex engine rewrites the shared config.toml per turn, so one user's engine can launch
222
+ * pointed at another user's per-turn credential state. Scoping the home per verified user
223
+ * makes any remaining config race same-user (same credential ⇒ harmless).
224
+ *
225
+ * Returns the base dir unchanged when no verified user is in scope: the IDE and
226
+ * single-tenant CLI paths have no `runWithCredential` frame and must be unaffected.
227
+ * The user id is hashed — it is an email/subject, never safe as a raw path segment.
228
+ */
229
+ function userScopedEngineHome(baseHome, verifiedUserId) {
230
+ const uid = verifiedUserId?.trim();
231
+ if (!uid) {
232
+ return baseHome;
233
+ }
234
+ const hash = crypto.createHash('sha256').update(uid).digest('hex').slice(0, 16);
235
+ return path.join(baseHome, 'users', hash);
236
+ }
214
237
  /**
215
238
  * Check if an agent is the default workspace agent.
216
239
  */
@@ -74,6 +74,9 @@ function formatFleetModelLabel(model, engine) {
74
74
  }
75
75
  return '—';
76
76
  }
77
+ if (raw === 'ah-auto' || raw.endsWith('::ah-auto')) {
78
+ return 'Auto';
79
+ }
77
80
  if (raw.startsWith('agentide-cursor::')) {
78
81
  const tail = raw.slice('agentide-cursor::'.length).trim() || 'auto';
79
82
  if (tail === 'auto') {
@@ -17,7 +17,7 @@ const panelApi = window.panelApi;
17
17
 
18
18
  // Seed/version marker for deciding whether a user's locally seeded panel.js
19
19
  // is compatible with the extension runtime (e.g. message forwarding fixes).
20
- const AGENTIDE_PANEL_JS_VERSION = '2026.05.30';
20
+ const AGENTIDE_PANEL_JS_VERSION = '2026.07.21';
21
21
  try { window.__AGENTIDE_PANEL_JS_VERSION__ = AGENTIDE_PANEL_JS_VERSION; } catch { /* no-op */ }
22
22
 
23
23
  // -----------------------------------------------------------------------------
@@ -44,7 +44,7 @@ function createDefaultPanelState() {
44
44
  return { version: 1, charts: {}, summaryHidden: false };
45
45
  }
46
46
 
47
- /** @type {{ artifacts: object[], galleryIgnorePatterns: string[], summaryCards: object[] | null, manifest: object | null, isRefreshing: boolean, scope: 'global'|'workspace', projectId: string, agentId: string, agentIds: string[], views: Array<{ id: string, name: string, scope: 'global'|'workspace', system: boolean, layout?: 'fleet-dashboard'|'gallery', refreshOnNavigate?: 'always'|'when-empty', preserveFleetIframe?: boolean }>, activeViewId: string, disabledCharts: Set<string>, panelState: PanelState, panelStateLoaded: boolean, lastArtifactCatalogSignature: string, fleetAutoRefreshIntervalSeconds: number, fleetRefreshVersion: number }} */
47
+ /** @type {{ artifacts: object[], galleryIgnorePatterns: string[], summaryCards: object[] | null, manifest: object | null, isRefreshing: boolean, scope: 'global'|'workspace', projectId: string, agentId: string, agentIds: string[], views: Array<{ id: string, name: string, scope: 'global'|'workspace', system: boolean, layout?: 'fleet-dashboard'|'gallery', refreshOnNavigate?: 'always'|'when-empty', preserveFleetIframe?: boolean }>, activeViewId: string, disabledCharts: Set<string>, panelState: PanelState, panelStateLoaded: boolean, lastArtifactCatalogSignature: string, fleetAutoRefreshIntervalSeconds: number, fleetRefreshVersion: number, needsGalleryRender: boolean, pendingForceFreshHtml: boolean, refreshingViewId: string | null }} */
48
48
  const state = {
49
49
  artifacts: [],
50
50
  galleryIgnorePatterns: [],
@@ -64,8 +64,64 @@ const state = {
64
64
  fleetAutoRefreshIntervalSeconds: 30,
65
65
  fleetRefreshVersion: 0,
66
66
  surfaceMode: 'spotlight',
67
+ // While a refresh is in flight, multiple artifactsUpdated bursts can arrive as scripts/
68
+ // watchers write files incrementally. Rendering on each one tears down and rebuilds the
69
+ // whole gallery (see renderGallery), which is visibly flickery. Defer to one render when
70
+ // the refresh actually ends (refreshFinished/refreshFailed/refreshNoData).
71
+ needsGalleryRender: false,
72
+ pendingForceFreshHtml: false,
73
+ // View id the in-flight refresh belongs to. Termination messages carry the viewId captured
74
+ // at refresh start, so after a mid-refresh view switch they no longer match activeViewId —
75
+ // match against this instead so isRefreshing cannot get stuck true.
76
+ refreshingViewId: null,
67
77
  };
68
78
 
79
+ /** Render the gallery now if a render was deferred mid-refresh. */
80
+ function flushPendingGalleryRender() {
81
+ if (!state.needsGalleryRender) {
82
+ return;
83
+ }
84
+ state.needsGalleryRender = false;
85
+ const forceFreshHtml = state.pendingForceFreshHtml;
86
+ state.pendingForceFreshHtml = false;
87
+ renderGallery(state.artifacts, { forceFreshHtml });
88
+ }
89
+
90
+ /**
91
+ * Single entry point for gallery renders triggered by host messages. While a refresh is in
92
+ * flight, defer to one render at refresh termination instead of tearing down and rebuilding
93
+ * the gallery on every artifactsUpdated/manifestUpdated/disabledChartsUpdated burst.
94
+ * @param {{ forceFreshHtml?: boolean }} [options]
95
+ */
96
+ function requestGalleryRender(options) {
97
+ if (state.isRefreshing) {
98
+ state.needsGalleryRender = true;
99
+ if (options?.forceFreshHtml) {
100
+ state.pendingForceFreshHtml = true;
101
+ }
102
+ return;
103
+ }
104
+ renderGallery(state.artifacts, { forceFreshHtml: Boolean(options?.forceFreshHtml) });
105
+ }
106
+
107
+ /**
108
+ * The active view changed while a refresh may be in flight: that refresh belongs to the view
109
+ * being left, so stop tracking it. The abandoned refresh's termination message is dropped by
110
+ * the viewId gates, and without this reset a deferred render would block the new view's first
111
+ * paint until that refresh ends (or forever, if no matching termination arrives).
112
+ */
113
+ function resetRefreshTrackingForViewChange() {
114
+ if (!state.isRefreshing && !state.needsGalleryRender && !state.pendingForceFreshHtml) {
115
+ return;
116
+ }
117
+ state.isRefreshing = false;
118
+ state.refreshingViewId = null;
119
+ state.needsGalleryRender = false;
120
+ state.pendingForceFreshHtml = false;
121
+ setRefreshButtonState(false);
122
+ setPerChartRefreshing(false);
123
+ }
124
+
69
125
  function applySurfaceMode(mode) {
70
126
  const team = mode === 'team';
71
127
  state.surfaceMode = team ? 'team' : 'spotlight';
@@ -362,6 +418,7 @@ function switchToView(nextViewId) {
362
418
  return;
363
419
  }
364
420
  state.activeViewId = nextViewId;
421
+ resetRefreshTrackingForViewChange();
365
422
  state.panelStateLoaded = false;
366
423
  state.panelState = createDefaultPanelState();
367
424
  const viewSelect = /** @type {HTMLSelectElement | null} */ (document.getElementById('view-select'));
@@ -849,6 +906,9 @@ function toggleChartHiddenInPanelState(filename) {
849
906
  // Summary Cards
850
907
  // -----------------------------------------------------------------------------
851
908
 
909
+ /** Last rendered summary-cards payload, to skip no-op rebuilds of the summary strip. */
910
+ let lastSummaryCardsSignature = '';
911
+
852
912
  /**
853
913
  * Render summary cards in the header
854
914
  * @param {Array<{label: string, value: string, icon: string}>} cards
@@ -859,10 +919,19 @@ function renderSummaryCards(cards) {
859
919
  return;
860
920
  }
861
921
  if (!cards || cards.length === 0) {
922
+ lastSummaryCardsSignature = '';
862
923
  renderSummaryPlaceholders();
863
924
  return;
864
925
  }
865
926
 
927
+ // The host posts summaryCards more than once per refresh; skip the rebuild (and its
928
+ // visible flash) when the payload is identical to what is already rendered.
929
+ const signature = JSON.stringify(cards);
930
+ if (signature === lastSummaryCardsSignature && !container.classList.contains('loading')) {
931
+ return;
932
+ }
933
+ lastSummaryCardsSignature = signature;
934
+
866
935
  container.style.display = 'grid';
867
936
  container.classList.remove('loading');
868
937
  container.innerHTML = cards.map(card => `
@@ -987,7 +1056,6 @@ function renderGallery(artifacts, options) {
987
1056
  }
988
1057
  return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
989
1058
  });
990
- const interactiveChartKeys = chartArtifacts.map((artifact) => normalizeFilename(String(artifact.filename || '')));
991
1059
  const nonChartArtifacts = visibleArtifacts
992
1060
  .filter((artifact) => artifact.type !== 'chart')
993
1061
  .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
@@ -1085,7 +1153,7 @@ function renderGallery(artifacts, options) {
1085
1153
 
1086
1154
  showGalleryState('content');
1087
1155
 
1088
- const html = orderedArtifacts.map(artifact => {
1156
+ const buildCardHtml = (artifact) => {
1089
1157
  const timeAgo = formatTimeAgo(new Date(artifact.modifiedAt));
1090
1158
  const sizeFormatted = formatSize(artifact.size);
1091
1159
  const isChart = artifact.type === 'chart';
@@ -1175,101 +1243,214 @@ function renderGallery(artifacts, options) {
1175
1243
  ${resizeHandle}
1176
1244
  </div>
1177
1245
  `;
1178
- }).join('');
1246
+ };
1247
+ // Keyed card reuse: diff incoming artifacts against existing cards by localPath and only
1248
+ // rebuild what changed. Rebuilding a card destroys its iframe/img and re-fetches content —
1249
+ // that teardown is the visible blink — so unchanged cards must not be touched at all.
1250
+ /** @type {Map<string, HTMLElement>} */
1251
+ const existingCards = new Map();
1252
+ Array.from(container.children).forEach((childEl) => {
1253
+ const child = /** @type {HTMLElement} */ (childEl);
1254
+ const childPath = child.dataset.path || '';
1255
+ const reusableAsCard =
1256
+ child.classList.contains('artifact-card')
1257
+ && !child.classList.contains('fleet-dashboard-full') // fleet layout card: different structure
1258
+ && !child.classList.contains('deleting')
1259
+ && childPath
1260
+ && !existingCards.has(childPath);
1261
+ if (!reusableAsCard) {
1262
+ child.remove();
1263
+ return;
1264
+ }
1265
+ existingCards.set(childPath, child);
1266
+ });
1179
1267
 
1180
- container.innerHTML = html;
1181
1268
  /** @type {Array<object>} */
1182
1269
  const htmlArtifactsNeedingFetch = [];
1183
1270
  /** @type {Array<object>} */
1184
1271
  const markdownArtifactsNeedingFetch = [];
1185
- container.querySelectorAll('.artifact-card').forEach((cardEl) => {
1186
- const card = /** @type {HTMLElement} */ (cardEl);
1187
- const path = card.dataset.path || '';
1188
- const modifiedAt = card.dataset.modifiedAt || '';
1272
+ /** @type {HTMLElement[]} */
1273
+ const orderedCards = [];
1274
+ for (const artifact of orderedArtifacts) {
1275
+ const path = String(artifact.localPath || '');
1276
+ const modifiedAt = String(artifact.modifiedAt || '');
1277
+ const isVisual = artifact.type === 'chart' || artifact.type === 'image';
1278
+ const chartState = isVisual ? artifact._panel : undefined;
1279
+ const signature = computeCardRenderSignature(artifact);
1280
+ const forceHtmlReload = forceFreshHtml && artifact.format === 'html';
1281
+ const existing = existingCards.get(path);
1282
+ existingCards.delete(path);
1283
+
1284
+ if (existing && existing.dataset.cardSignature === signature && !forceHtmlReload) {
1285
+ // Unchanged: leave the DOM (and any live iframe) alone. Expanded/height are kept
1286
+ // out of the signature and applied in place so toggling them never reloads content.
1287
+ if (isVisual) {
1288
+ existing.classList.toggle('expanded', chartState?.expanded === true);
1289
+ const contentEl = /** @type {HTMLElement | null} */ (existing.querySelector('.artifact-content'));
1290
+ if (contentEl && chartState?.height) {
1291
+ contentEl.style.height = `${chartState.height}px`;
1292
+ contentEl.style.minHeight = `${chartState.height}px`;
1293
+ contentEl.style.overflow = 'auto';
1294
+ }
1295
+ }
1296
+ const timeEl = existing.querySelector('.artifact-time');
1297
+ if (timeEl) {
1298
+ timeEl.textContent = formatTimeAgo(new Date(artifact.modifiedAt));
1299
+ }
1300
+ orderedCards.push(existing);
1301
+ continue;
1302
+ }
1303
+
1304
+ const template = document.createElement('template');
1305
+ template.innerHTML = buildCardHtml(artifact).trim();
1306
+ const card = /** @type {HTMLElement | null} */ (template.content.firstElementChild);
1307
+ if (!card) {
1308
+ continue;
1309
+ }
1310
+ card.dataset.cardSignature = signature;
1189
1311
  const htmlHost = /** @type {HTMLElement | null} */ (card.querySelector('.html-preview'));
1190
- if (htmlHost && path && modifiedAt) {
1191
- const reusable = forceFreshHtml ? undefined : reusableHtmlPreviews.get(`${path}::${modifiedAt}`);
1312
+ if (htmlHost) {
1313
+ const reusable = forceHtmlReload ? undefined : reusableHtmlPreviews.get(`${path}::${modifiedAt}`);
1192
1314
  if (reusable) {
1193
1315
  htmlHost.classList.add('ready');
1194
1316
  htmlHost.innerHTML = '';
1195
1317
  htmlHost.appendChild(reusable);
1196
- } else {
1197
- const htmlArtifact = orderedArtifacts.find((item) => item.localPath === path && item.format === 'html');
1198
- if (htmlArtifact) {
1199
- htmlArtifactsNeedingFetch.push(htmlArtifact);
1200
- }
1318
+ } else if (artifact.format === 'html') {
1319
+ htmlArtifactsNeedingFetch.push(artifact);
1201
1320
  }
1202
1321
  }
1322
+ if (card.querySelector('.markdown-preview')) {
1323
+ markdownArtifactsNeedingFetch.push(artifact);
1324
+ }
1325
+ attachArtifactCardHandlers(card);
1326
+ if (existing) {
1327
+ existing.replaceWith(card);
1328
+ }
1329
+ orderedCards.push(card);
1330
+ }
1203
1331
 
1204
- const markdownHost = /** @type {HTMLElement | null} */ (card.querySelector('.markdown-preview'));
1205
- if (markdownHost && path) {
1206
- const markdownArtifact = orderedArtifacts.find((item) => item.localPath === path && item.format === 'md');
1207
- if (markdownArtifact) {
1208
- markdownArtifactsNeedingFetch.push(markdownArtifact);
1209
- }
1332
+ // Cards whose artifacts disappeared.
1333
+ existingCards.forEach((card) => card.remove());
1334
+
1335
+ // Minimal-move reorder: re-inserting a node reloads its iframe, so only move cards that
1336
+ // are actually out of position.
1337
+ let cursor = container.firstElementChild;
1338
+ for (const card of orderedCards) {
1339
+ if (card === cursor) {
1340
+ cursor = cursor.nextElementSibling;
1341
+ continue;
1210
1342
  }
1211
- });
1343
+ container.insertBefore(card, cursor);
1344
+ }
1345
+
1212
1346
  requestHtmlPreviewContents(htmlArtifactsNeedingFetch);
1213
1347
  requestMarkdownPreviewContents(markdownArtifactsNeedingFetch);
1214
1348
 
1215
- // Attach event handlers
1216
- container.querySelectorAll('.artifact-card').forEach(cardEl => {
1217
- const card = /** @type {HTMLElement} */ (cardEl);
1218
- const path = card.dataset.path;
1219
- const filename = card.dataset.filename;
1220
- const isChart = card.dataset.type === 'chart';
1221
- const isVisual = card.dataset.type === 'chart' || card.dataset.type === 'image';
1349
+ // Markdown previews are loaded via host message passing (artifactContent)
1350
+ // to avoid fetch restrictions for local report files.
1351
+ }
1222
1352
 
1223
- card.querySelector('.open-btn')?.addEventListener('click', (e) => {
1353
+ /**
1354
+ * Per-card render signature: every input that affects a card's DOM except expanded/height,
1355
+ * which renderGallery applies in place so toggling them never rebuilds the card.
1356
+ * @param {object} artifact
1357
+ * @returns {string}
1358
+ */
1359
+ function computeCardRenderSignature(artifact) {
1360
+ const isVisual = artifact.type === 'chart' || artifact.type === 'image';
1361
+ const chartState = isVisual ? artifact._panel : undefined;
1362
+ const manifestEntry = state.manifest?.artifacts?.find(e => e.filename === artifact.filename);
1363
+ return [
1364
+ String(artifact.localPath || ''),
1365
+ String(artifact.filename || ''),
1366
+ String(artifact.modifiedAt || ''),
1367
+ String(artifact.size || ''),
1368
+ String(artifact.type || ''),
1369
+ String(artifact.format || ''),
1370
+ String(artifact.generator || ''),
1371
+ String(manifestEntry?.title || ''),
1372
+ String(manifestEntry?.source || ''),
1373
+ chartState?.hidden === true ? '1' : '0',
1374
+ Number.isFinite(chartState?.order) ? String(chartState.order) : '',
1375
+ ].join('|');
1376
+ }
1377
+
1378
+ /**
1379
+ * Chart/image ordering keys derived from current DOM order — computed at click time so
1380
+ * handlers on long-lived cards never act on a stale snapshot.
1381
+ * @returns {string[]}
1382
+ */
1383
+ function getInteractiveChartKeysFromDom() {
1384
+ const container = document.getElementById('gallery-content');
1385
+ if (!container) {
1386
+ return [];
1387
+ }
1388
+ return Array.from(container.querySelectorAll('.artifact-card'))
1389
+ .filter((cardEl) => {
1390
+ const card = /** @type {HTMLElement} */ (cardEl);
1391
+ return card.dataset.type === 'chart' || card.dataset.type === 'image';
1392
+ })
1393
+ .map((cardEl) => normalizeFilename(String(/** @type {HTMLElement} */ (cardEl).dataset.filename || '')))
1394
+ .filter(Boolean);
1395
+ }
1396
+
1397
+ /**
1398
+ * Wire interactive behaviors for one gallery card (open/delete, layout buttons, expand on
1399
+ * click, resize handle). Called once per created card; reused cards keep their listeners.
1400
+ * @param {HTMLElement} card
1401
+ */
1402
+ function attachArtifactCardHandlers(card) {
1403
+ const path = card.dataset.path;
1404
+ const filename = card.dataset.filename;
1405
+ const isChart = card.dataset.type === 'chart';
1406
+ const isVisual = card.dataset.type === 'chart' || card.dataset.type === 'image';
1407
+
1408
+ card.querySelector('.open-btn')?.addEventListener('click', (e) => {
1409
+ e.stopPropagation();
1410
+ panelApi.postMessage({ type: 'openArtifact', path });
1411
+ });
1412
+
1413
+ card.querySelector('.delete-btn')?.addEventListener('click', (e) => {
1414
+ e.stopPropagation();
1415
+ panelApi.postMessage({ type: 'deleteArtifact', path });
1416
+ });
1417
+
1418
+ if (isChart && filename) {
1419
+ card.querySelector('.move-up-btn')?.addEventListener('click', (e) => {
1224
1420
  e.stopPropagation();
1225
- panelApi.postMessage({ type: 'openArtifact', path });
1421
+ moveChartInPanelState(filename, 'up', getInteractiveChartKeysFromDom());
1422
+ renderGallery(state.artifacts);
1226
1423
  });
1227
1424
 
1228
- card.querySelector('.delete-btn')?.addEventListener('click', (e) => {
1425
+ card.querySelector('.move-down-btn')?.addEventListener('click', (e) => {
1229
1426
  e.stopPropagation();
1230
- panelApi.postMessage({ type: 'deleteArtifact', path });
1427
+ moveChartInPanelState(filename, 'down', getInteractiveChartKeysFromDom());
1428
+ renderGallery(state.artifacts);
1231
1429
  });
1232
1430
 
1233
- if (isChart && filename) {
1234
- card.querySelector('.move-up-btn')?.addEventListener('click', (e) => {
1235
- e.stopPropagation();
1236
- moveChartInPanelState(filename, 'up', interactiveChartKeys);
1237
- renderGallery(state.artifacts);
1238
- });
1239
-
1240
- card.querySelector('.move-down-btn')?.addEventListener('click', (e) => {
1241
- e.stopPropagation();
1242
- moveChartInPanelState(filename, 'down', interactiveChartKeys);
1243
- renderGallery(state.artifacts);
1244
- });
1245
-
1246
- card.querySelector('.toggle-enabled-btn')?.addEventListener('click', (e) => {
1247
- e.stopPropagation();
1248
- toggleChartHiddenInPanelState(filename);
1249
- renderGallery(state.artifacts);
1250
- });
1251
- }
1252
-
1253
- // Click on card to expand/collapse
1254
- card.addEventListener('click', (event) => {
1255
- const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null);
1256
- if (target?.closest('button,.resize-handle')) {
1257
- return;
1258
- }
1259
- const nextExpanded = !card.classList.contains('expanded');
1260
- card.classList.toggle('expanded', nextExpanded);
1261
- if (isVisual && filename) {
1262
- setChartExpanded(filename, nextExpanded);
1263
- }
1431
+ card.querySelector('.toggle-enabled-btn')?.addEventListener('click', (e) => {
1432
+ e.stopPropagation();
1433
+ toggleChartHiddenInPanelState(filename);
1434
+ renderGallery(state.artifacts);
1264
1435
  });
1436
+ }
1265
1437
 
1266
- if (filename) {
1267
- attachResizeHandle(card, filename);
1438
+ // Click on card to expand/collapse
1439
+ card.addEventListener('click', (event) => {
1440
+ const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null);
1441
+ if (target?.closest('button,.resize-handle')) {
1442
+ return;
1443
+ }
1444
+ const nextExpanded = !card.classList.contains('expanded');
1445
+ card.classList.toggle('expanded', nextExpanded);
1446
+ if (isVisual && filename) {
1447
+ setChartExpanded(filename, nextExpanded);
1268
1448
  }
1269
1449
  });
1270
1450
 
1271
- // Markdown previews are loaded via host message passing (artifactContent)
1272
- // to avoid fetch restrictions for local report files.
1451
+ if (filename) {
1452
+ attachResizeHandle(card, filename);
1453
+ }
1273
1454
  }
1274
1455
 
1275
1456
  /**
@@ -2044,12 +2225,18 @@ panelApi.onMessage(msg => {
2044
2225
  state.artifacts = msg.artifacts;
2045
2226
  state.galleryIgnorePatterns = nextGalleryIgnore;
2046
2227
  // View selection is owned by viewsLoaded; do not let artifact batches revert the dropdown.
2228
+ const prevActiveViewId = state.activeViewId;
2047
2229
  if (
2048
2230
  nextActiveViewId === state.activeViewId
2049
2231
  || !state.views.some((view) => view.id === state.activeViewId)
2050
2232
  ) {
2051
2233
  state.activeViewId = nextActiveViewId;
2052
2234
  }
2235
+ if (state.activeViewId !== prevActiveViewId) {
2236
+ // Host-initiated view change (e.g. openWithView): any in-flight refresh belongs
2237
+ // to the previous view — render now instead of deferring behind it.
2238
+ resetRefreshTrackingForViewChange();
2239
+ }
2053
2240
  state.fleetRefreshVersion = nextFleetRefreshVersion;
2054
2241
  applyFleetAutoRefresh();
2055
2242
  if (exportOnlyCatalogChange) {
@@ -2059,10 +2246,11 @@ panelApi.onMessage(msg => {
2059
2246
  // Fleet JSON can change without agent-fleet.html mtime changing if the host
2060
2247
  // did not re-run the widget script; bumping fleetRefreshVersion means data is
2061
2248
  // fresher — force a preview reload so the iframe is not reused with stale HTML.
2062
- renderGallery(state.artifacts, { forceFreshHtml: hasFleetVersionUpdate });
2063
- if (state.isRefreshing) {
2064
- setPerChartRefreshing(true);
2065
- }
2249
+ if (state.isRefreshing) {
2250
+ // Mid-refresh burst: keep spinners on; requestGalleryRender defers the rebuild.
2251
+ setPerChartRefreshing(true);
2252
+ }
2253
+ requestGalleryRender({ forceFreshHtml: hasFleetVersionUpdate });
2066
2254
  break;
2067
2255
  }
2068
2256
 
@@ -2093,7 +2281,7 @@ panelApi.onMessage(msg => {
2093
2281
  state.manifest = msg.manifest;
2094
2282
  // Re-render gallery with new manifest data
2095
2283
  if (state.artifacts.length > 0) {
2096
- renderGallery(state.artifacts);
2284
+ requestGalleryRender();
2097
2285
  }
2098
2286
  break;
2099
2287
 
@@ -2137,6 +2325,10 @@ panelApi.onMessage(msg => {
2137
2325
  case 'generatingStarted':
2138
2326
  setRefreshButtonState(true);
2139
2327
  state.isRefreshing = false;
2328
+ state.refreshingViewId = null;
2329
+ // Drop any deferred render — a later flush would stomp the generating state.
2330
+ state.needsGalleryRender = false;
2331
+ state.pendingForceFreshHtml = false;
2140
2332
  setPerChartRefreshing(false);
2141
2333
  showGeneratingState();
2142
2334
  break;
@@ -2144,6 +2336,9 @@ panelApi.onMessage(msg => {
2144
2336
  case 'generatingCancelled':
2145
2337
  setRefreshButtonState(false);
2146
2338
  state.isRefreshing = false;
2339
+ state.refreshingViewId = null;
2340
+ state.needsGalleryRender = false;
2341
+ state.pendingForceFreshHtml = false;
2147
2342
  setPerChartRefreshing(false);
2148
2343
  // Go back to empty or content state
2149
2344
  if (state.artifacts.length > 0) {
@@ -2156,6 +2351,9 @@ panelApi.onMessage(msg => {
2156
2351
  case 'generatingFailed':
2157
2352
  setRefreshButtonState(false);
2158
2353
  state.isRefreshing = false;
2354
+ state.refreshingViewId = null;
2355
+ state.needsGalleryRender = false;
2356
+ state.pendingForceFreshHtml = false;
2159
2357
  setPerChartRefreshing(false);
2160
2358
  showFailedState(msg.error);
2161
2359
  break;
@@ -2164,15 +2362,18 @@ panelApi.onMessage(msg => {
2164
2362
  if (!msg.viewId || msg.viewId === state.activeViewId) {
2165
2363
  setRefreshButtonState(true);
2166
2364
  state.isRefreshing = true;
2365
+ state.refreshingViewId = typeof msg.viewId === 'string' && msg.viewId ? msg.viewId : null;
2167
2366
  setPerChartRefreshing(true);
2168
2367
  }
2169
2368
  break;
2170
2369
 
2171
2370
  case 'refreshFinished':
2172
- if (!msg.viewId || msg.viewId === state.activeViewId) {
2371
+ if (!msg.viewId || msg.viewId === state.activeViewId || msg.viewId === state.refreshingViewId) {
2173
2372
  setRefreshButtonState(false);
2174
2373
  state.isRefreshing = false;
2374
+ state.refreshingViewId = null;
2175
2375
  setPerChartRefreshing(false);
2376
+ flushPendingGalleryRender();
2176
2377
  if (activeViewPreservesFleetIframe()) {
2177
2378
  document.querySelectorAll('.html-preview iframe').forEach((frameEl) => {
2178
2379
  const iframe = /** @type {HTMLIFrameElement} */ (frameEl);
@@ -2186,29 +2387,38 @@ panelApi.onMessage(msg => {
2186
2387
  }
2187
2388
  break;
2188
2389
 
2189
- case 'viewsLoaded':
2390
+ case 'viewsLoaded': {
2190
2391
  if (msg.surfaceMode === 'team' || msg.surfaceMode === 'spotlight') {
2191
2392
  applySurfaceMode(msg.surfaceMode);
2192
2393
  }
2394
+ const prevViewsLoadedViewId = state.activeViewId;
2193
2395
  state.views = Array.isArray(msg.views) ? msg.views : [];
2194
2396
  state.activeViewId = typeof msg.activeViewId === 'string' ? msg.activeViewId : state.activeViewId;
2397
+ if (state.activeViewId !== prevViewsLoadedViewId) {
2398
+ resetRefreshTrackingForViewChange();
2399
+ }
2195
2400
  applyViewOptions();
2196
2401
  applyFleetAutoRefresh();
2197
2402
  break;
2403
+ }
2198
2404
 
2199
2405
  case 'refreshNoData':
2200
- if (!msg.viewId || msg.viewId === state.activeViewId) {
2406
+ if (!msg.viewId || msg.viewId === state.activeViewId || msg.viewId === state.refreshingViewId) {
2201
2407
  setRefreshButtonState(false);
2202
2408
  state.isRefreshing = false;
2409
+ state.refreshingViewId = null;
2203
2410
  setPerChartRefreshing(false);
2411
+ flushPendingGalleryRender();
2204
2412
  }
2205
2413
  break;
2206
2414
 
2207
2415
  case 'refreshFailed':
2208
- if (!msg.viewId || msg.viewId === state.activeViewId) {
2416
+ if (!msg.viewId || msg.viewId === state.activeViewId || msg.viewId === state.refreshingViewId) {
2209
2417
  setRefreshButtonState(false);
2210
2418
  state.isRefreshing = false;
2419
+ state.refreshingViewId = null;
2211
2420
  setPerChartRefreshing(false);
2421
+ flushPendingGalleryRender();
2212
2422
  if (state.artifacts.length === 0) {
2213
2423
  showFailedState(msg.error);
2214
2424
  }
@@ -2256,7 +2466,7 @@ panelApi.onMessage(msg => {
2256
2466
  .map((name) => String(name || '').trim().toLowerCase())
2257
2467
  .filter(Boolean),
2258
2468
  );
2259
- renderGallery(state.artifacts);
2469
+ requestGalleryRender();
2260
2470
  break;
2261
2471
 
2262
2472
  case 'dataStale':