@copilotkit/web-inspector 1.64.2-canary.rc-1 → 1.64.2

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.
@@ -236,6 +236,26 @@ function getContextInternals(inspector: WebInspectorElement) {
236
236
  return inspector as unknown as InspectorContextInternals;
237
237
  }
238
238
 
239
+ type TelemetryPost = { event: string; properties: Record<string, unknown> };
240
+
241
+ /** Decode the `oss.inspector.*` payloads a stubbed fetch received. */
242
+ function telemetryPostsFrom(fetchMock: {
243
+ mock: { calls: unknown[][] };
244
+ }): TelemetryPost[] {
245
+ return fetchMock.mock.calls
246
+ .filter(
247
+ (call) =>
248
+ String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
249
+ (call[1] as RequestInit | undefined)?.method === "POST",
250
+ )
251
+ .map(
252
+ (call) =>
253
+ JSON.parse(
254
+ ((call[1] as RequestInit | undefined)?.body as string) ?? "{}",
255
+ ) as TelemetryPost,
256
+ );
257
+ }
258
+
239
259
  // --- Tests ---
240
260
 
241
261
  describe("WebInspectorElement", () => {
@@ -1161,6 +1181,14 @@ describe("CpkThreadInspector provider contract", () => {
1161
1181
  vi.stubGlobal("fetch", fetchMock);
1162
1182
  const { el, internals } = createThreadInspector();
1163
1183
 
1184
+ // Count only thread-inspection requests. This stub replaces the global
1185
+ // fetch, so a fire-and-forget telemetry POST from an earlier case can land
1186
+ // here too; the assertion is about refetches, not total network calls.
1187
+ const threadFetches = () =>
1188
+ fetchMock.mock.calls.filter((call) =>
1189
+ String(call[0]).startsWith("http://runtime"),
1190
+ );
1191
+
1164
1192
  internals.runtimeUrl = "http://runtime";
1165
1193
  internals.threadInspectionAvailable = true;
1166
1194
  internals.headers = { Authorization: "Bearer first" };
@@ -1168,7 +1196,7 @@ describe("CpkThreadInspector provider contract", () => {
1168
1196
  await flushProviderWork(el);
1169
1197
 
1170
1198
  await vi.waitFor(() => {
1171
- expect(fetchMock).toHaveBeenCalledTimes(1);
1199
+ expect(threadFetches()).toHaveLength(1);
1172
1200
  expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1173
1201
  auth: "Bearer first",
1174
1202
  });
@@ -1178,12 +1206,12 @@ describe("CpkThreadInspector provider contract", () => {
1178
1206
  await flushProviderWork(el);
1179
1207
 
1180
1208
  await vi.waitFor(() => {
1181
- expect(fetchMock).toHaveBeenCalledTimes(2);
1209
+ expect(threadFetches()).toHaveLength(2);
1182
1210
  expect(internals._fetchedEvents?.[0]?.payload).toEqual({
1183
1211
  auth: "Bearer second",
1184
1212
  });
1185
1213
  });
1186
- expect(headersOf(fetchMock.mock.calls.at(-1)!)).toMatchObject({
1214
+ expect(headersOf(threadFetches().at(-1)!)).toMatchObject({
1187
1215
  Authorization: "Bearer second",
1188
1216
  });
1189
1217
  });
@@ -1473,6 +1501,36 @@ describe("WebInspectorElement announcement preview dismissal", () => {
1473
1501
  expect(a.isOpen).toBe(false);
1474
1502
  });
1475
1503
 
1504
+ it("emits banner_dismissed with the bubble surface alongside banner_clicked", async () => {
1505
+ const fetchMock = vi
1506
+ .spyOn(globalThis, "fetch")
1507
+ .mockResolvedValue(new Response(null, { status: 204 }));
1508
+ const timestamp = "2026-06-11T13:00:00.000Z";
1509
+ const { inspector } = await mountWithUnseenAnnouncement(timestamp);
1510
+
1511
+ inspector.shadowRoot
1512
+ ?.querySelector<HTMLElement>(".announcement-preview__dismiss")
1513
+ ?.click();
1514
+ await inspector.updateComplete;
1515
+
1516
+ const posts = telemetryPostsFrom(fetchMock);
1517
+ const dismissed = posts.find(
1518
+ (post) => post.event === "oss.inspector.banner_dismissed",
1519
+ );
1520
+ expect(dismissed?.properties).toMatchObject({
1521
+ banner_id: timestamp,
1522
+ surface: "collapsed_preview",
1523
+ });
1524
+ // The legacy signal keeps flowing so existing dashboards don't zero out.
1525
+ const clicked = posts.find(
1526
+ (post) => post.event === "oss.inspector.banner_clicked",
1527
+ );
1528
+ expect(clicked?.properties).toMatchObject({
1529
+ banner_id: timestamp,
1530
+ cta: "dismiss",
1531
+ });
1532
+ });
1533
+
1476
1534
  it("clicking the popout body opens the inspector without persisting", async () => {
1477
1535
  const { inspector, a } = await mountWithUnseenAnnouncement(
1478
1536
  "2026-06-11T13:00:00.000Z",
@@ -1491,6 +1549,258 @@ describe("WebInspectorElement announcement preview dismissal", () => {
1491
1549
  });
1492
1550
  });
1493
1551
 
1552
+ // ─────────────────────────────────────────────────────────────────────────
1553
+ // Panel-open + banner-surface telemetry (OSS-566 / OSS-568)
1554
+ // ─────────────────────────────────────────────────────────────────────────
1555
+ //
1556
+ // `oss.inspector.opened` exists because opens were previously only inferable
1557
+ // from in-panel activity (a floor) or from `banner_clicked` cta=body (which
1558
+ // misses the floating-button path). `banner_viewed` carries a `surface`
1559
+ // because the bubble on the collapsed widget and the card inside the opened
1560
+ // panel are separate impressions — reach on one says nothing about the other.
1561
+
1562
+ const ANNOUNCEMENT_URL = "https://cdn.copilotkit.ai/announcements.json";
1563
+
1564
+ type OpenTelemetryInternals = {
1565
+ isOpen: boolean;
1566
+ announcementTimestamp: string | null;
1567
+ fetchAnnouncement: () => Promise<void>;
1568
+ openInspector: (source: "floating_button" | "announcement_preview") => void;
1569
+ };
1570
+
1571
+ describe("WebInspectorElement open + banner surface telemetry", () => {
1572
+ let fetchMock: ReturnType<typeof vi.fn>;
1573
+ const timestamp = "2026-07-01T09:00:00.000Z";
1574
+
1575
+ beforeEach(() => {
1576
+ document.body.innerHTML = "";
1577
+ fetchMock = vi.fn((input: unknown) => {
1578
+ if (String(input) === ANNOUNCEMENT_URL) {
1579
+ return Promise.resolve(
1580
+ new Response(
1581
+ JSON.stringify({
1582
+ timestamp,
1583
+ previewText: "Channels are here",
1584
+ announcement: "Channels are here — [read more](https://x.test)",
1585
+ }),
1586
+ { status: 200 },
1587
+ ),
1588
+ );
1589
+ }
1590
+ return Promise.resolve(new Response(null, { status: 204 }));
1591
+ });
1592
+ vi.stubGlobal("fetch", fetchMock);
1593
+ });
1594
+
1595
+ afterEach(() => {
1596
+ vi.unstubAllGlobals();
1597
+ });
1598
+
1599
+ /** Mount an inspector attached to a connected core with telemetry on. */
1600
+ function mount(telemetryDisabled = false, connected = true) {
1601
+ const { agent } = createMockAgent("alpha");
1602
+ const harness = createHeaderMockCore(
1603
+ { alpha: agent },
1604
+ {},
1605
+ {},
1606
+ telemetryDisabled,
1607
+ );
1608
+ if (!connected) {
1609
+ harness.core.runtimeConnectionStatus =
1610
+ CopilotKitCoreRuntimeConnectionStatus.Disconnected;
1611
+ }
1612
+ const inspector = new WebInspectorElement();
1613
+ document.body.appendChild(inspector);
1614
+ inspector.core = harness.core as unknown as WebInspectorElement["core"];
1615
+ return {
1616
+ inspector,
1617
+ harness,
1618
+ internals: inspector as unknown as OpenTelemetryInternals,
1619
+ };
1620
+ }
1621
+
1622
+ const posts = () => telemetryPostsFrom(fetchMock);
1623
+ const eventsNamed = (name: string) =>
1624
+ posts().filter((post) => post.event === name);
1625
+
1626
+ it("records the collapsed bubble impression, then the in-panel card on open", async () => {
1627
+ const { inspector, internals } = mount();
1628
+
1629
+ await internals.fetchAnnouncement();
1630
+ await inspector.updateComplete;
1631
+
1632
+ const first = eventsNamed("oss.inspector.banner_viewed");
1633
+ expect(first).toHaveLength(1);
1634
+ expect(first[0]!.properties).toMatchObject({
1635
+ banner_id: timestamp,
1636
+ surface: "collapsed_preview",
1637
+ });
1638
+
1639
+ // Opening reveals the in-panel card — a distinct impression.
1640
+ inspector.shadowRoot
1641
+ ?.querySelector<HTMLElement>(".announcement-preview")
1642
+ ?.click();
1643
+ await inspector.updateComplete;
1644
+
1645
+ const surfaces = eventsNamed("oss.inspector.banner_viewed").map(
1646
+ (post) => post.properties.surface,
1647
+ );
1648
+ expect(surfaces).toEqual(["collapsed_preview", "expanded_card"]);
1649
+ });
1650
+
1651
+ it("does not re-record an impression for a surface already seen this mount", async () => {
1652
+ const { inspector, internals } = mount();
1653
+
1654
+ await internals.fetchAnnouncement();
1655
+ await internals.fetchAnnouncement();
1656
+ await inspector.updateComplete;
1657
+
1658
+ expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(1);
1659
+ });
1660
+
1661
+ it("emits opened with the floating-button source and announcement context", async () => {
1662
+ const { inspector, internals } = mount();
1663
+
1664
+ await internals.fetchAnnouncement();
1665
+ await inspector.updateComplete;
1666
+
1667
+ inspector.shadowRoot
1668
+ ?.querySelector<HTMLElement>('button[aria-label="Web Inspector"]')
1669
+ ?.click();
1670
+ await inspector.updateComplete;
1671
+
1672
+ const opened = eventsNamed("oss.inspector.opened");
1673
+ expect(opened).toHaveLength(1);
1674
+ expect(opened[0]!.properties).toMatchObject({
1675
+ open_source: "floating_button",
1676
+ has_unseen_announcement: true,
1677
+ package_name: "@copilotkit/web-inspector",
1678
+ });
1679
+ });
1680
+
1681
+ it("attributes an open through the announcement bubble to that surface", async () => {
1682
+ const { inspector, internals } = mount();
1683
+
1684
+ await internals.fetchAnnouncement();
1685
+ await inspector.updateComplete;
1686
+
1687
+ inspector.shadowRoot
1688
+ ?.querySelector<HTMLElement>(".announcement-preview")
1689
+ ?.click();
1690
+ await inspector.updateComplete;
1691
+
1692
+ expect(eventsNamed("oss.inspector.opened")[0]!.properties).toMatchObject({
1693
+ open_source: "announcement_preview",
1694
+ });
1695
+ });
1696
+
1697
+ it("counts one open per open, and nothing for an already-open panel", async () => {
1698
+ const { inspector, internals } = mount();
1699
+ await inspector.updateComplete;
1700
+
1701
+ internals.openInspector("floating_button");
1702
+ internals.openInspector("floating_button");
1703
+ await inspector.updateComplete;
1704
+
1705
+ expect(eventsNamed("oss.inspector.opened")).toHaveLength(1);
1706
+ });
1707
+
1708
+ it("does not count a restored-open panel as an open", async () => {
1709
+ const { inspector, internals } = mount();
1710
+ // Restoring persisted state assigns isOpen directly rather than calling
1711
+ // openInspector — otherwise every reload / dev-server hot reload would
1712
+ // register as a fresh open.
1713
+ internals.isOpen = true;
1714
+ inspector.requestUpdate();
1715
+ await inspector.updateComplete;
1716
+
1717
+ expect(eventsNamed("oss.inspector.opened")).toHaveLength(0);
1718
+ });
1719
+
1720
+ // An open while disconnected is still a real open, so it is reported — but the
1721
+ // /info-derived dimensions are omitted rather than guessed. Recording the
1722
+ // `sse` default would permanently misattribute an Intelligence runtime.
1723
+ it("omits license and runtime mode for an open before the handshake", async () => {
1724
+ const { inspector, internals } = mount(false, false);
1725
+
1726
+ internals.openInspector("floating_button");
1727
+ await inspector.updateComplete;
1728
+
1729
+ const opened = eventsNamed("oss.inspector.opened");
1730
+ expect(opened).toHaveLength(1);
1731
+ expect(opened[0]!.properties).toMatchObject({
1732
+ open_source: "floating_button",
1733
+ // Config-derived, so accurate immediately.
1734
+ runtime_url_type: "localhost",
1735
+ });
1736
+ expect(opened[0]!.properties).not.toHaveProperty("runtime_mode");
1737
+ expect(opened[0]!.properties).not.toHaveProperty("license_status");
1738
+ });
1739
+
1740
+ it("records license and runtime mode for an open against a connected Intelligence runtime", async () => {
1741
+ const { inspector, harness, internals } = mount(false, false);
1742
+ harness.completeHandshake({
1743
+ telemetryDisabled: false,
1744
+ runtimeMode: "intelligence",
1745
+ licenseStatus: "valid",
1746
+ });
1747
+ await inspector.updateComplete;
1748
+
1749
+ internals.openInspector("floating_button");
1750
+ await inspector.updateComplete;
1751
+
1752
+ expect(eventsNamed("oss.inspector.opened")[0]!.properties).toMatchObject({
1753
+ open_source: "floating_button",
1754
+ runtime_mode: "intelligence",
1755
+ license_status: "valid",
1756
+ });
1757
+ });
1758
+
1759
+ // The impression deferral predates the surface split and must survive it: the
1760
+ // runtime's opt-out only arrives with /info.
1761
+ it("holds a banner impression until the handshake, then drops it when telemetry is disabled", async () => {
1762
+ const { inspector, harness, internals } = mount(false, false);
1763
+
1764
+ await internals.fetchAnnouncement();
1765
+ await inspector.updateComplete;
1766
+ expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1767
+
1768
+ harness.completeHandshake({ telemetryDisabled: true });
1769
+ await inspector.updateComplete;
1770
+
1771
+ expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1772
+ });
1773
+
1774
+ it("releases a held banner impression once the runtime allows telemetry", async () => {
1775
+ const { inspector, harness, internals } = mount(false, false);
1776
+
1777
+ await internals.fetchAnnouncement();
1778
+ await inspector.updateComplete;
1779
+ expect(eventsNamed("oss.inspector.banner_viewed")).toHaveLength(0);
1780
+
1781
+ harness.completeHandshake({ telemetryDisabled: false });
1782
+ await inspector.updateComplete;
1783
+
1784
+ const viewed = eventsNamed("oss.inspector.banner_viewed");
1785
+ expect(viewed).toHaveLength(1);
1786
+ expect(viewed[0]!.properties).toMatchObject({
1787
+ surface: "collapsed_preview",
1788
+ });
1789
+ });
1790
+
1791
+ it("emits nothing when the runtime has telemetry disabled", async () => {
1792
+ const { inspector, internals } = mount(true);
1793
+
1794
+ await internals.fetchAnnouncement();
1795
+ inspector.shadowRoot
1796
+ ?.querySelector<HTMLElement>(".announcement-preview")
1797
+ ?.click();
1798
+ await inspector.updateComplete;
1799
+
1800
+ expect(posts()).toEqual([]);
1801
+ });
1802
+ });
1803
+
1494
1804
  // --- Owned thread store header forwarding (issue #5581) ---
1495
1805
  //
1496
1806
  // When useThreads() isn't mounted, the inspector creates its own thread store
@@ -1503,6 +1813,12 @@ type HeaderMockCore = {
1503
1813
  context: Record<string, unknown>;
1504
1814
  properties: Record<string, unknown>;
1505
1815
  telemetryDisabled: boolean;
1816
+ // Mirrors CopilotKitCore's pre-handshake state (`agent-registry.ts:77`):
1817
+ // `runtimeMode` already reads its `sse` default and `licenseStatus` is absent,
1818
+ // both of which only become real once /info answers. Without this, telemetry
1819
+ // assertions about pre-handshake segmentation pass for the wrong reason.
1820
+ runtimeMode: string;
1821
+ licenseStatus?: string;
1506
1822
  runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
1507
1823
  runtimeUrl: string;
1508
1824
  headers: Record<string, string>;
@@ -1534,6 +1850,7 @@ function createHeaderMockCore(
1534
1850
  context: {},
1535
1851
  properties: {},
1536
1852
  telemetryDisabled,
1853
+ runtimeMode: "sse",
1537
1854
  runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
1538
1855
  runtimeUrl: "http://localhost/api",
1539
1856
  headers,
@@ -1575,6 +1892,29 @@ function createHeaderMockCore(
1575
1892
  s.onHeadersChanged?.({ copilotkit: asCore(), headers: nextHeaders }),
1576
1893
  );
1577
1894
  },
1895
+ /**
1896
+ * Simulates the /info handshake landing: applies what the runtime reported,
1897
+ * then transitions to `connected`. Lets a test observe what is sent after
1898
+ * the handshake versus before it — the segmentation fields do not exist
1899
+ * until this point.
1900
+ */
1901
+ completeHandshake(
1902
+ reported: {
1903
+ telemetryDisabled?: boolean;
1904
+ licenseStatus?: string;
1905
+ runtimeMode?: string;
1906
+ } = {},
1907
+ ) {
1908
+ Object.assign(core, reported);
1909
+ core.runtimeConnectionStatus =
1910
+ CopilotKitCoreRuntimeConnectionStatus.Connected;
1911
+ subscribers.forEach((s) =>
1912
+ s.onRuntimeConnectionStatusChanged?.({
1913
+ copilotkit: asCore(),
1914
+ status: core.runtimeConnectionStatus,
1915
+ }),
1916
+ );
1917
+ },
1578
1918
  };
1579
1919
  }
1580
1920
 
package/src/index.ts CHANGED
@@ -61,7 +61,9 @@ import {
61
61
  getTelemetryDistinctIdForUrl,
62
62
  maybeShowDisclosure,
63
63
  trackBannerClicked,
64
+ trackBannerDismissed,
64
65
  trackBannerViewed,
66
+ trackInspectorOpened,
65
67
  trackTalkToEngineerClicked,
66
68
  trackThreadsEmptyEnabledViewed,
67
69
  trackThreadsEnabledViewed,
@@ -79,7 +81,9 @@ import {
79
81
  trackThreadsTalkToEngineerClicked,
80
82
  } from "./lib/telemetry.js";
81
83
  import type {
84
+ BannerSurface,
82
85
  InspectorMemoryTelemetryProps,
86
+ InspectorOpenSource,
83
87
  InspectorThreadTelemetryProps,
84
88
  } from "./lib/telemetry.js";
85
89
 
@@ -127,6 +131,9 @@ const DEFAULT_BUTTON_SIZE: Size = { width: 48, height: 48 };
127
131
  const DEFAULT_WINDOW_SIZE: Size = { width: 840, height: 700 };
128
132
  const DOCKED_LEFT_WIDTH = 500; // Sensible width for left dock with collapsed sidebar
129
133
  const MAX_AGENT_EVENTS = 200;
134
+ // Cap on banner impressions held while waiting for the runtime handshake, so a
135
+ // runtime that never connects can't accumulate an unbounded queue.
136
+ const MAX_PENDING_BANNER_VIEWED = 20;
130
137
  const MAX_TOTAL_EVENTS = 500;
131
138
  const INTELLIGENCE_SIGNUP_URL = "https://go.copilotkit.ai/intelligence-signup";
132
139
  const THREADS_INTELLIGENCE_SIGNIN_URL =
@@ -4556,16 +4563,27 @@ export class WebInspectorElement extends LitElement {
4556
4563
  private announcementPromise: Promise<void> | null = null;
4557
4564
  private showAnnouncementPreview = true;
4558
4565
  private announcementExpanded = false;
4559
- // Per-instance dedup for `oss.inspector.banner_viewed` so the event fires
4560
- // at most once per announcement timestamp per inspector mount. Plan calls
4561
- // for "de-dup per timestamp per session"; instance-scoping is closer
4562
- // to per-mount than per-tab (sessionStorage), but for the inspector the
4563
- // distinction is academic — inspector instances rarely outlive the page.
4564
- private viewedBannerTimestamps: Set<string> = new Set();
4565
- private pendingBannerViewed: {
4566
+ // Per-instance dedup for `oss.inspector.banner_viewed`, keyed by
4567
+ // `${timestamp}:${surface}` so the event fires at most once per
4568
+ // announcement per surface per inspector mount. Plan calls for "de-dup per
4569
+ // timestamp per session"; instance-scoping is closer to per-mount than
4570
+ // per-tab (sessionStorage), but for the inspector the distinction is
4571
+ // academic — inspector instances rarely outlive the page. The surface is
4572
+ // part of the key (OSS-568) because the bubble on the collapsed widget and
4573
+ // the card inside the opened panel are separate impressions: a user can see
4574
+ // one without ever seeing the other.
4575
+ private viewedBannerSurfaces: Set<string> = new Set();
4576
+ // Impressions wait for the runtime handshake before going out: the runtime's
4577
+ // opt-out arrives in the /info response, and `telemetryDisabled` reads `false`
4578
+ // until then, so sending directly would post on a placeholder. This deferral
4579
+ // predates the surface split — preserved here, widened from a single slot to a
4580
+ // queue because opening the panel reveals the second surface and can happen
4581
+ // before the first impression has flushed.
4582
+ private pendingBannerViewed: Array<{
4566
4583
  banner_id: string;
4584
+ surface: BannerSurface;
4567
4585
  cta_label?: string;
4568
- } | null = null;
4586
+ }> = [];
4569
4587
  // Per-instance dedup for `oss.inspector.banner_clicked` (keyed by
4570
4588
  // `${bannerId}:${cta}`) so copy-button retries and accidental multi-clicks
4571
4589
  // don't inflate funnel counts beyond one signal per intent type per banner.
@@ -7255,7 +7273,7 @@ ${argsString}</pre
7255
7273
  !this.isOpen &&
7256
7274
  !this.draggedDuringInteraction
7257
7275
  ) {
7258
- this.openInspector();
7276
+ this.openInspector("floating_button");
7259
7277
  }
7260
7278
 
7261
7279
  this.resetPointerTracking();
@@ -7288,7 +7306,7 @@ ${argsString}</pre
7288
7306
 
7289
7307
  if (!this.isOpen) {
7290
7308
  event.preventDefault();
7291
- this.openInspector();
7309
+ this.openInspector("floating_button");
7292
7310
  }
7293
7311
  };
7294
7312
 
@@ -7759,11 +7777,12 @@ ${argsString}</pre
7759
7777
  this.draggedDuringInteraction = false;
7760
7778
  }
7761
7779
 
7762
- private openInspector(): void {
7780
+ private openInspector(source: InspectorOpenSource): void {
7763
7781
  if (this.isOpen) {
7764
7782
  return;
7765
7783
  }
7766
7784
 
7785
+ const hadUnseenAnnouncement = this.hasUnseenAnnouncement;
7767
7786
  this.showAnnouncementPreview = false; // hide the bubble once the inspector is opened
7768
7787
 
7769
7788
  this.ensureAnnouncementLoading();
@@ -7771,6 +7790,12 @@ ${argsString}</pre
7771
7790
  this.isOpen = true;
7772
7791
  this.persistState(); // Save the open state
7773
7792
 
7793
+ this.trackOpened(source, hadUnseenAnnouncement);
7794
+ // The in-panel announcement card is now the visible surface, so it earns
7795
+ // its own banner_viewed impression (no-op when the announcement hasn't
7796
+ // loaded yet — fetchAnnouncement records it on arrival instead).
7797
+ this.maybeTrackBannerViewed();
7798
+
7774
7799
  // Apply docking styles if in docked mode
7775
7800
  if (this.dockMode !== "floating") {
7776
7801
  this.applyDockStyles();
@@ -8290,6 +8315,52 @@ ${argsString}</pre
8290
8315
  `;
8291
8316
  }
8292
8317
 
8318
+ // Fires `oss.inspector.opened` for a user-initiated open (OSS-566).
8319
+ // Restoring a persisted-open panel on mount assigns `isOpen` directly
8320
+ // instead of routing through `openInspector`, so page reloads and dev-server
8321
+ // hot reloads are not counted as opens.
8322
+ private trackOpened(
8323
+ source: InspectorOpenSource,
8324
+ hadUnseenAnnouncement: boolean,
8325
+ ): void {
8326
+ if (this.core?.telemetryDisabled) return;
8327
+ // `license_status` and `runtime_mode` come from /info. Before the handshake
8328
+ // `licenseStatus` is undefined and `runtimeMode` reads its `sse` default, so
8329
+ // recording them would permanently attribute an early open against an
8330
+ // Intelligence runtime to SSE. Omitted rather than guessed — an absent
8331
+ // dimension is honest, a wrong one is not. `runtime_url_type` is derived
8332
+ // from configuration, so it is accurate immediately.
8333
+ const handshakeComplete = this.runtimeStatus === "connected";
8334
+ trackInspectorOpened({
8335
+ open_source: source,
8336
+ ...(handshakeComplete
8337
+ ? {
8338
+ license_status: this.core?.licenseStatus ?? undefined,
8339
+ runtime_mode: this.core?.runtimeMode ?? undefined,
8340
+ }
8341
+ : {}),
8342
+ runtime_url_type: getRuntimeUrlType(this.core?.runtimeUrl),
8343
+ has_unseen_announcement: hadUnseenAnnouncement,
8344
+ });
8345
+ }
8346
+
8347
+ // Fires `banner_dismissed` at most once per `${bannerId}:${surface}` per
8348
+ // mount. Emitted alongside `banner_clicked { cta: "dismiss" }` rather than
8349
+ // replacing it, so dashboards reading the `cta` value keep working.
8350
+ private trackBannerDismissedOnce(surface: BannerSurface): void {
8351
+ if (this.core?.telemetryDisabled) return;
8352
+ const id = this.announcementTimestamp;
8353
+ if (!id) return;
8354
+ const key = `${id}:dismissed:${surface}`;
8355
+ if (this.clickedBannerIds.has(key)) return;
8356
+ this.clickedBannerIds.add(key);
8357
+ trackBannerDismissed({
8358
+ banner_id: id,
8359
+ surface,
8360
+ cta_label: this.announcementCtaLabel ?? undefined,
8361
+ });
8362
+ }
8363
+
8293
8364
  // Fires `banner_clicked` at most once per `${bannerId}:${cta}` per mount so
8294
8365
  // copy-button retries and accidental multi-clicks don't inflate funnel counts.
8295
8366
  private trackBannerClickedOnce(opts: { cta: "body" | "dismiss" }): void {
@@ -11332,7 +11403,7 @@ ${prettyEvent}</pre
11332
11403
  <button
11333
11404
  class="announcement-dismiss ml-auto"
11334
11405
  type="button"
11335
- @click=${this.handleDismissAnnouncement}
11406
+ @click=${() => this.dismissAnnouncement("expanded_card")}
11336
11407
  aria-label="Dismiss announcement"
11337
11408
  >
11338
11409
  ${this.renderIcon("X")}
@@ -11372,14 +11443,56 @@ ${prettyEvent}</pre
11372
11443
  </div>`;
11373
11444
  }
11374
11445
 
11446
+ /**
11447
+ * Which announcement surface is on screen right now, or null when the
11448
+ * announcement isn't visible. Mirrors the render gates in
11449
+ * `renderAnnouncementPreview` (bubble) and `renderAnnouncementBanner`
11450
+ * (in-panel card) — opening the panel clears `showAnnouncementPreview`, so
11451
+ * the two are mutually exclusive.
11452
+ */
11453
+ private getVisibleBannerSurface(): BannerSurface | null {
11454
+ if (!this.hasUnseenAnnouncement) return null;
11455
+ if (this.isOpen) {
11456
+ return this.announcementHtml ? "expanded_card" : null;
11457
+ }
11458
+ return this.showAnnouncementPreview && this.announcementPreviewText
11459
+ ? "collapsed_preview"
11460
+ : null;
11461
+ }
11462
+
11463
+ /**
11464
+ * Records a `banner_viewed` impression for whichever surface is currently
11465
+ * visible, once per announcement per surface.
11466
+ */
11467
+ private maybeTrackBannerViewed(): void {
11468
+ const id = this.announcementTimestamp;
11469
+ if (!id) return;
11470
+ const surface = this.getVisibleBannerSurface();
11471
+ if (!surface) return;
11472
+ const key = `${id}:${surface}`;
11473
+ if (this.viewedBannerSurfaces.has(key)) return;
11474
+ if (this.pendingBannerViewed.length >= MAX_PENDING_BANNER_VIEWED) return;
11475
+ this.viewedBannerSurfaces.add(key);
11476
+ this.pendingBannerViewed.push({
11477
+ banner_id: id,
11478
+ surface,
11479
+ cta_label: this.announcementCtaLabel ?? undefined,
11480
+ });
11481
+ this.flushPendingBannerViewed();
11482
+ }
11483
+
11484
+ // Releases held impressions once /info has answered, or discards them when it
11485
+ // reports telemetry disabled.
11375
11486
  private flushPendingBannerViewed(): void {
11376
- if (!this.pendingBannerViewed || this.core?.telemetryDisabled) {
11377
- this.pendingBannerViewed = null;
11487
+ if (this.pendingBannerViewed.length === 0) return;
11488
+ if (this.core?.telemetryDisabled) {
11489
+ this.pendingBannerViewed = [];
11378
11490
  return;
11379
11491
  }
11380
11492
  if (this.runtimeStatus !== "connected") return;
11381
- trackBannerViewed(this.pendingBannerViewed);
11382
- this.pendingBannerViewed = null;
11493
+ const queued = this.pendingBannerViewed;
11494
+ this.pendingBannerViewed = [];
11495
+ for (const props of queued) trackBannerViewed(props);
11383
11496
  }
11384
11497
 
11385
11498
  private ensureAnnouncementLoading(): void {
@@ -11429,7 +11542,7 @@ ${prettyEvent}</pre
11429
11542
 
11430
11543
  private handleAnnouncementPreviewClick(): void {
11431
11544
  this.showAnnouncementPreview = false;
11432
- this.openInspector();
11545
+ this.openInspector("announcement_preview");
11433
11546
  }
11434
11547
 
11435
11548
  // Dismissing the preview bubble must PERSIST via markAnnouncementSeen(),
@@ -11442,13 +11555,14 @@ ${prettyEvent}</pre
11442
11555
  // Don't let the dismiss bubble to the preview body, whose click opens the
11443
11556
  // inspector.
11444
11557
  event.stopPropagation();
11445
- this.handleDismissAnnouncement();
11558
+ this.dismissAnnouncement("collapsed_preview");
11446
11559
  };
11447
11560
 
11448
- private handleDismissAnnouncement = (): void => {
11561
+ private dismissAnnouncement(surface: BannerSurface): void {
11449
11562
  this.trackBannerClickedOnce({ cta: "dismiss" });
11563
+ this.trackBannerDismissedOnce(surface);
11450
11564
  this.markAnnouncementSeen();
11451
- };
11565
+ }
11452
11566
 
11453
11567
  private async fetchAnnouncement(): Promise<void> {
11454
11568
  try {
@@ -11489,21 +11603,9 @@ ${prettyEvent}</pre
11489
11603
  this.announcementHtml = await this.convertMarkdownToHtml(markdown);
11490
11604
  this.announcementLoaded = true;
11491
11605
 
11492
- // banner_viewed: gate on actual visibility and per-mount dedup.
11493
- // Store as pending rather than firing immediately — telemetryDisabled
11494
- // may not be known yet if /info hasn't returned. Flushed in
11495
- // onRuntimeConnectionStatusChanged once the handshake completes.
11496
- if (
11497
- this.hasUnseenAnnouncement &&
11498
- !this.viewedBannerTimestamps.has(timestamp)
11499
- ) {
11500
- this.viewedBannerTimestamps.add(timestamp);
11501
- this.pendingBannerViewed = {
11502
- banner_id: timestamp,
11503
- cta_label: ctaLabel ?? undefined,
11504
- };
11505
- this.flushPendingBannerViewed();
11506
- }
11606
+ // banner_viewed: gate on actual visibility and per-mount dedup, and
11607
+ // stamp the surface the announcement is showing on right now.
11608
+ this.maybeTrackBannerViewed();
11507
11609
 
11508
11610
  this.requestUpdate();
11509
11611
  } catch (error) {