@runappsai/sdk 0.0.0-stage → 0.3.1

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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/dist/activity-tracker.d.ts +113 -0
  4. package/dist/activity-tracker.d.ts.map +1 -0
  5. package/dist/activity-tracker.js +176 -0
  6. package/dist/activity-tracker.js.map +1 -0
  7. package/dist/audio.d.ts +129 -0
  8. package/dist/audio.d.ts.map +1 -0
  9. package/dist/audio.js +267 -0
  10. package/dist/audio.js.map +1 -0
  11. package/dist/browser-auth.d.ts +179 -0
  12. package/dist/browser-auth.d.ts.map +1 -0
  13. package/dist/browser-auth.js +1361 -0
  14. package/dist/browser-auth.js.map +1 -0
  15. package/dist/chat.d.ts +221 -0
  16. package/dist/chat.d.ts.map +1 -0
  17. package/dist/chat.js +155 -0
  18. package/dist/chat.js.map +1 -0
  19. package/dist/client.d.ts +155 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +157 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/computer.d.ts +57 -0
  24. package/dist/computer.d.ts.map +1 -0
  25. package/dist/computer.js +21 -0
  26. package/dist/computer.js.map +1 -0
  27. package/dist/embeddings.d.ts +64 -0
  28. package/dist/embeddings.d.ts.map +1 -0
  29. package/dist/embeddings.js +30 -0
  30. package/dist/embeddings.js.map +1 -0
  31. package/dist/errors.d.ts +24 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +29 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/event-wrap.d.ts +59 -0
  36. package/dist/event-wrap.d.ts.map +1 -0
  37. package/dist/event-wrap.js +142 -0
  38. package/dist/event-wrap.js.map +1 -0
  39. package/dist/events.d.ts +119 -0
  40. package/dist/events.d.ts.map +1 -0
  41. package/dist/events.js +87 -0
  42. package/dist/events.js.map +1 -0
  43. package/dist/files.d.ts +218 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +212 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/image.d.ts +185 -0
  48. package/dist/image.d.ts.map +1 -0
  49. package/dist/image.js +148 -0
  50. package/dist/image.js.map +1 -0
  51. package/dist/index.d.ts +30 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +26 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/media.d.ts +56 -0
  56. package/dist/media.d.ts.map +1 -0
  57. package/dist/media.js +133 -0
  58. package/dist/media.js.map +1 -0
  59. package/dist/model_options.d.ts +165 -0
  60. package/dist/model_options.d.ts.map +1 -0
  61. package/dist/model_options.js +59 -0
  62. package/dist/model_options.js.map +1 -0
  63. package/dist/models.d.ts +88 -0
  64. package/dist/models.d.ts.map +1 -0
  65. package/dist/models.js +55 -0
  66. package/dist/models.js.map +1 -0
  67. package/dist/sdk.umd.js +4 -0
  68. package/dist/sdk.umd.js.map +1 -0
  69. package/dist/transport.d.ts +120 -0
  70. package/dist/transport.d.ts.map +1 -0
  71. package/dist/transport.js +214 -0
  72. package/dist/transport.js.map +1 -0
  73. package/dist/types.d.ts +31 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +2 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/validate.d.ts +16 -0
  78. package/dist/validate.d.ts.map +1 -0
  79. package/dist/validate.js +329 -0
  80. package/dist/validate.js.map +1 -0
  81. package/dist/video.d.ts +134 -0
  82. package/dist/video.d.ts.map +1 -0
  83. package/dist/video.js +60 -0
  84. package/dist/video.js.map +1 -0
  85. package/package.json +54 -4
  86. package/src/activity-tracker.ts +251 -0
  87. package/src/audio.ts +440 -0
  88. package/src/browser-auth.ts +1529 -0
  89. package/src/chat.ts +368 -0
  90. package/src/client.ts +250 -0
  91. package/src/computer.ts +80 -0
  92. package/src/embeddings.ts +97 -0
  93. package/src/errors.ts +29 -0
  94. package/src/event-wrap.ts +168 -0
  95. package/src/events.ts +172 -0
  96. package/src/files.ts +347 -0
  97. package/src/image.ts +339 -0
  98. package/src/index.ts +151 -0
  99. package/src/media.ts +147 -0
  100. package/src/model_options.ts +220 -0
  101. package/src/models.ts +121 -0
  102. package/src/transport.ts +292 -0
  103. package/src/types.ts +31 -0
  104. package/src/validate.ts +364 -0
  105. package/src/video.ts +215 -0
@@ -0,0 +1,1529 @@
1
+ // browser-auth.ts — opt-in runapps.ai auth flow for browser bundles.
2
+ //
3
+ // Activated by `new RunApps({ authProvider: "runjobs", baseURL: "..." })`.
4
+ // In Node and other non-window environments this module's runtime code
5
+ // short-circuits to no-ops; it ships with the IIFE bundle for resource
6
+ // bundles loaded via <script> tag.
7
+ //
8
+ // Responsibilities:
9
+ // - Detect & consume `#runjobs_token=…` URL fragment planted by the
10
+ // grant page after a successful sign-in.
11
+ // - Trigger a redirect-based grant flow when the bundle calls a
12
+ // gateway endpoint without a fresh token.
13
+ // - In iframe contexts (the runjobs dashboard embedding), handshake
14
+ // with the parent via postMessage to silently obtain a token.
15
+ // - Render an unobtrusive identity badge in the bottom-right corner
16
+ // so the end user can see WHICH runjobs account is paying for the
17
+ // calls being made on their behalf.
18
+ //
19
+ // Why redirect (not popup): under COOP=same-origin (required so the
20
+ // bundle can use OPFS / SharedArrayBuffer), the browser severs the
21
+ // window.opener relationship for cross-origin popups, so popup-based
22
+ // grant flows silently lose their postMessage replies.
23
+
24
+ import { ActivityTracker, type ActivitySnapshot, type SessionStats } from "./activity-tracker.js";
25
+ import type { SDKEvents } from "./events.js";
26
+
27
+ export interface BrowserAuthOptions {
28
+ /**
29
+ * Origin where the RunApps gateway lives — also the page that issues
30
+ * tokens at /api/sdk/grant. Default `https://www.runapps.ai`.
31
+ */
32
+ origin?: string;
33
+ /**
34
+ * Suppress the floating identity badge. Default `false` at this
35
+ * layer. The wrapping `RunApps` client passes `hideBadge: false`
36
+ * by default (badge SHOWN — it's now a live activity indicator,
37
+ * see `activity-tracker.ts`); callers wanting their own status UI
38
+ * opt out via `RunApps({ showIdentityBadge: false })`. Set this
39
+ * directly only when constructing BrowserAuth without going
40
+ * through `RunApps`.
41
+ */
42
+ hideBadge?: boolean;
43
+ /**
44
+ * Pin the grant flow to a named runapps.ai project. When set, the
45
+ * SDK passes `project_id=<value>` to `/api/sdk/grant`, asking the
46
+ * gateway to mint a project-bound `rrt_*` resource token for THAT
47
+ * project regardless of which (origin, app) pair the bundle is
48
+ * served from.
49
+ *
50
+ * Use cases:
51
+ * - **Local dev** for a 1:1 bundle/project app: the localhost
52
+ * origin isn't registered with any project, so without this
53
+ * option the grant flow can't pick one and `client.files` 403s
54
+ * on every call. Pin the project explicitly and `pnpm dev`
55
+ * hits the same files namespace as production.
56
+ * - **Multi-tenant bundles** that let the user pick a project at
57
+ * runtime — pass the chosen project id, then call `signOut()` +
58
+ * `signIn()` (or reconstruct the client) on switch.
59
+ *
60
+ * Omit to keep the default behaviour: the gateway derives the
61
+ * project from the registered (origin, app) pair.
62
+ */
63
+ project?: string;
64
+ /**
65
+ * Optional event bus from the parent RunApps client. When wired,
66
+ * the identity badge subscribes to call telemetry events and
67
+ * renders a real-time activity ring + LED dot + click-through
68
+ * popover with active / recent / session stats — turning the
69
+ * static "signed in" badge into a desktop-ball-style live
70
+ * indicator (similar to a tray-app gauge).
71
+ *
72
+ * Optional and back-compat: when omitted (or when `hideBadge` is
73
+ * true), the badge falls back to its original static behaviour.
74
+ */
75
+ events?: SDKEvents;
76
+ /**
77
+ * Which corner the badge floats in. See `RunApps.badgePosition`
78
+ * for the user-facing flavour. Defaults to `"bottom-right"`.
79
+ */
80
+ badgePosition?: BadgePosition;
81
+ }
82
+
83
+ /** Four-corner anchor for the floating activity badge. */
84
+ export type BadgePosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
85
+
86
+ export interface BrowserUser {
87
+ id?: string;
88
+ name?: string;
89
+ }
90
+
91
+ const TOKEN_REFRESH_MARGIN_S = 60;
92
+ // Per-project localStorage namespacing. When two BrowserAuth bundles
93
+ // live on the same origin (most commonly `localhost:5173` during
94
+ // development of multiple SDK-using apps, or a deployed origin that
95
+ // hosts more than one bundle), a single shared key would let them
96
+ // clobber each other's tokens — bundle B's signIn() would overwrite
97
+ // the token bundle A had cached, and the next time A booted it'd
98
+ // either reuse B's token (wrong project scope, every gateway call
99
+ // returns 404 on `files/...`) or skip the cache and redirect through
100
+ // the grant flow on every reload. Append the constructor-supplied
101
+ // `project` to the key so bundles scoped to different projects keep
102
+ // independent slots in localStorage. Bundles without an explicit
103
+ // project (iframe / parent-handshake mode) fall back to the legacy
104
+ // unsuffixed key, which preserves back-compat for already-persisted
105
+ // tokens after upgrade.
106
+ const STORAGE_KEY_BASE = "__runjobs_auth_v1__";
107
+ // Set when the user clicks "Sign out". While present, getToken() must
108
+ // not silently redirect to the grant page even though the user still
109
+ // has a runapps.ai cookie — otherwise the backend's auto-grant turns
110
+ // every page reload into a re-authentication. Cleared on explicit
111
+ // signIn() so the user can come back. Same per-project namespacing
112
+ // rationale as STORAGE_KEY_BASE — signing out of bundle A shouldn't
113
+ // also sign out bundle B that happens to share the origin.
114
+ const SIGNED_OUT_KEY_BASE = "__runjobs_signed_out_v1__";
115
+
116
+ /** Compose the localStorage key for an auth slot. `null` project
117
+ * yields the base key (iframe-mode and pre-v1.x bundles), which
118
+ * preserves back-compat for tokens persisted before this change. */
119
+ function storageKey(project: string | null): string {
120
+ return project ? `${STORAGE_KEY_BASE}:${project}` : STORAGE_KEY_BASE;
121
+ }
122
+
123
+ /** Same as storageKey, for the sticky sign-out flag. */
124
+ function signedOutKey(project: string | null): string {
125
+ return project ? `${SIGNED_OUT_KEY_BASE}:${project}` : SIGNED_OUT_KEY_BASE;
126
+ }
127
+
128
+ interface PersistedAuth {
129
+ token: string;
130
+ expiresAt: number;
131
+ user?: BrowserUser;
132
+ origin: string;
133
+ }
134
+
135
+ /**
136
+ * BrowserAuth encapsulates the browser-side auth state for a RunApps
137
+ * client. Exposes:
138
+ *
139
+ * - getToken() fresh bearer (auto-refreshes / signs in)
140
+ * - signIn() force a redirect to the grant page
141
+ * - user { id, name } — null until signed in
142
+ * - onTokenChange(fn) subscribe to token (re)acquisition
143
+ *
144
+ * Construct once per page; the SDK passes its `getToken` as the
145
+ * transport's `apiKeyResolver`.
146
+ */
147
+ export class BrowserAuth {
148
+ private readonly origin: string;
149
+ private readonly hideBadge: boolean;
150
+ private readonly project: string | null;
151
+ private readonly events: SDKEvents | null;
152
+ /** Lazily-constructed when events are present — drives the badge's
153
+ * activity ring / LED / popover. nullable for tests / non-browser. */
154
+ private readonly tracker: ActivityTracker | null;
155
+ private readonly badgePosition: BadgePosition;
156
+ private token: string | null = null;
157
+ private expiresAt = 0;
158
+ private userInfo: BrowserUser | null = null;
159
+ private listeners: Array<(token: string) => void> = [];
160
+ private parentHandshake: Promise<void> | null = null;
161
+ private signingIn = false;
162
+
163
+ constructor(opts: BrowserAuthOptions = {}) {
164
+ this.origin = (opts.origin ?? "https://www.runapps.ai").replace(/\/$/, "");
165
+ this.hideBadge = !!opts.hideBadge;
166
+ this.project = opts.project ?? null;
167
+ this.events = opts.events ?? null;
168
+ this.badgePosition = opts.badgePosition ?? "bottom-right";
169
+ if (this.events && !this.hideBadge) {
170
+ this.tracker = new ActivityTracker();
171
+ this.tracker.attach(this.events);
172
+ } else {
173
+ this.tracker = null;
174
+ }
175
+ if (typeof window === "undefined") return;
176
+
177
+ // Restore from localStorage (survives page reloads). We pin the
178
+ // saved token to the gateway origin: a bundle that switches
179
+ // between dev / prod gateways shouldn't accidentally reuse the
180
+ // previous one's token.
181
+ this.loadPersisted();
182
+
183
+ // Eager: install token from the URL fragment if the grant page
184
+ // just redirected us back here. This always WINS over a
185
+ // localStorage-cached token because a fresh redirect implies the
186
+ // user just (re)authenticated and the old cache may be stale.
187
+ // A successful fragment install also clears the "signed out"
188
+ // sticky flag — the user explicitly signed in again.
189
+ if (this.consumeFragment()) {
190
+ this.clearSignedOut();
191
+ }
192
+
193
+ if (this.isSignedOut()) {
194
+ // Honour the user's explicit sign-out across reloads. Don't
195
+ // attempt parent handshake or render the badge.
196
+ } else if (this.inIframe() && !this.tokenIsFresh()) {
197
+ // Best-effort parent handshake — silent if it succeeds, falls
198
+ // through to the standard fragment / redirect path otherwise.
199
+ this.parentHandshake = this.requestTokenFromParent().catch(() => {
200
+ /* dashboard parent unavailable; will redirect on next call */
201
+ });
202
+ } else if (this.tokenIsFresh() && this.userInfo && !this.hideBadge) {
203
+ // Restored from cache — render the badge so the user can see
204
+ // they're already signed in (and find the sign-out affordance
205
+ // their bundle author exposed).
206
+ this.renderBadge();
207
+ }
208
+ }
209
+
210
+ /** Public token-fetcher; pass to RunApps as `apiKeyResolver`. */
211
+ getToken = async (): Promise<string> => {
212
+ if (this.tokenIsFresh()) return this.token as string;
213
+
214
+ // User explicitly signed out — don't silently re-auth. Throw so
215
+ // the caller surfaces an unauthenticated state and can prompt the
216
+ // user to click signIn. Without this guard, the backend's
217
+ // auto-grant flow would re-issue a token on every page reload
218
+ // (the cookie on the gateway domain is still valid).
219
+ if (this.isSignedOut()) {
220
+ throw new Error("RunApps: signed out — call client.signIn() to authenticate");
221
+ }
222
+
223
+ // ── Embedded (cross-site iframe) path ──
224
+ //
225
+ // Inside a cross-site iframe (the dashboard's bundle embed),
226
+ // signIn() → /api/sdk/grant is a DEAD END: navigating the iframe to
227
+ // the gateway origin is a *sub-frame* navigation, so the session
228
+ // cookie is NOT sent (SameSite) and grant bounces to a login form
229
+ // the user can't complete in place. The dashboard parent — same
230
+ // origin as the gateway, and it holds the cookie — is the ONLY
231
+ // party that can mint a token here. So we RE-handshake with the
232
+ // parent on EVERY stale token (not just once at construction).
233
+ // This is what lets a bundle survive token expiry / revocation
234
+ // without ever dropping to the login screen.
235
+ if (this.inIframe()) {
236
+ try {
237
+ // Reuse the eager construction-time handshake on the first call
238
+ // (avoids a duplicate post), then consume it so later staleness
239
+ // (expiry / 401-invalidate) starts a fresh handshake instead.
240
+ if (this.parentHandshake) {
241
+ await this.parentHandshake;
242
+ this.parentHandshake = null;
243
+ }
244
+ if (!this.tokenIsFresh()) {
245
+ await this.requestTokenFromParent();
246
+ }
247
+ } catch {
248
+ /* parent slow / unreachable — fall through to the throw */
249
+ }
250
+ if (this.tokenIsFresh()) return this.token as string;
251
+ // Never signIn() inside an iframe (cookie-less grant dead end).
252
+ // Surface as unauthenticated so the bundle can retry / show state.
253
+ throw new Error("RunApps: could not obtain a token from the dashboard");
254
+ }
255
+
256
+ // ── Top-level (own tab / window) path ──
257
+ //
258
+ // signIn() → grant works here because the cookie IS sent on a
259
+ // top-level navigation.
260
+ if (this.parentHandshake) {
261
+ await this.parentHandshake;
262
+ if (this.tokenIsFresh()) return this.token as string;
263
+ }
264
+ // No fresh token + can't get one silently → redirect to grant.
265
+ // The page is about to unload; return a never-resolving Promise so
266
+ // the caller doesn't proceed with a half-issued request.
267
+ this.signIn();
268
+ return new Promise(() => {});
269
+ };
270
+
271
+ /** Subscribe to (re)acquisition events. Returns an unsubscribe fn. */
272
+ onTokenChange(handler: (token: string) => void): () => void {
273
+ this.listeners.push(handler);
274
+ return () => {
275
+ const i = this.listeners.indexOf(handler);
276
+ if (i >= 0) this.listeners.splice(i, 1);
277
+ };
278
+ }
279
+
280
+ /** Currently-authenticated user, or null. */
281
+ get user(): BrowserUser | null {
282
+ return this.userInfo;
283
+ }
284
+
285
+ /**
286
+ * True iff a cached bearer token is present AND not within the
287
+ * 60-second pre-expiry refresh margin. Use this — not `user !==
288
+ * null` — to decide whether to show "please sign in" UI: the user
289
+ * metadata is only populated when the SDK was fed fresh user info
290
+ * (post-redirect fragment carries it; iframe handshake carries it;
291
+ * an older localStorage save format may have persisted only the
292
+ * token). A token without metadata is still a fully-usable
293
+ * session — every gateway call will succeed — and prompting the
294
+ * user to sign in there causes a redirect that loses session
295
+ * continuity for no reason.
296
+ */
297
+ hasFreshToken(): boolean {
298
+ return this.tokenIsFresh();
299
+ }
300
+
301
+ /**
302
+ * Clear the cached token / user / badge and the persisted copy in
303
+ * localStorage. Next gateway call will trigger a redirect to the
304
+ * grant page — i.e. the standard sign-in flow. Wire this to a
305
+ * "Sign out" button in the bundle's settings UI.
306
+ *
307
+ * Does NOT redirect on its own; the bundle decides whether to
308
+ * also navigate the user (e.g. to a logged-out splash page) or
309
+ * just refresh state and let the next API call re-prompt.
310
+ */
311
+ signOut(): void {
312
+ this.token = null;
313
+ this.expiresAt = 0;
314
+ this.userInfo = null;
315
+ this.parentHandshake = null;
316
+ this.signingIn = false;
317
+ this.clearPersisted();
318
+ this.markSignedOut();
319
+ this.removeBadge();
320
+ }
321
+
322
+ /**
323
+ * Drop the in-memory + persisted token without setting the sticky
324
+ * "signed out" flag. Used by the transport layer when the gateway
325
+ * returns 401 (token revoked server-side, e.g. user unsubscribed in
326
+ * another tab). The next `getToken()` will trigger the standard
327
+ * `signIn()` redirect-grant flow and the user re-auths transparently.
328
+ */
329
+ invalidate(): void {
330
+ this.token = null;
331
+ this.expiresAt = 0;
332
+ this.userInfo = null;
333
+ this.parentHandshake = null;
334
+ this.signingIn = false;
335
+ this.clearPersisted();
336
+ }
337
+
338
+ /** Force a redirect to the grant page. */
339
+ signIn(): void {
340
+ if (typeof window === "undefined" || this.signingIn) return;
341
+ // Explicit signIn clears the "signed out" flag so getToken() can
342
+ // proceed with the redirect-grant flow.
343
+ this.clearSignedOut();
344
+ this.signingIn = true;
345
+ const ret = location.href.split("#")[0] ?? location.href;
346
+ const scheme = window.matchMedia?.("(prefers-color-scheme: dark)")
347
+ .matches
348
+ ? "dark"
349
+ : "light";
350
+ location.href = this.buildGrantUrl({
351
+ pageOrigin: location.origin,
352
+ app: location.host,
353
+ redirectTo: ret,
354
+ scheme,
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Build the `/api/sdk/grant?…` URL the user is redirected to. Split
360
+ * out so tests can assert URL formation without a `window` context;
361
+ * `signIn` is the only production caller.
362
+ *
363
+ * Public surface, but namespaced under `_buildGrantUrlForTest` so it
364
+ * doesn't show up in IntelliSense as a normal API.
365
+ */
366
+ _buildGrantUrlForTest(args: {
367
+ pageOrigin: string;
368
+ app: string;
369
+ redirectTo: string;
370
+ scheme: "light" | "dark";
371
+ }): string {
372
+ return this.buildGrantUrl(args);
373
+ }
374
+
375
+ /**
376
+ * Return the localStorage keys this instance reads / writes for its
377
+ * persisted token + sign-out flag. Exposed for tests so we can
378
+ * assert per-project namespacing without a real DOM; not part of
379
+ * the public API surface (the leading underscore + `ForTest` suffix
380
+ * is the SDK's convention for this).
381
+ */
382
+ _storageKeysForTest(): { auth: string; signedOut: string } {
383
+ return {
384
+ auth: storageKey(this.project),
385
+ signedOut: signedOutKey(this.project),
386
+ };
387
+ }
388
+
389
+ private buildGrantUrl(args: {
390
+ pageOrigin: string;
391
+ app: string;
392
+ redirectTo: string;
393
+ scheme: "light" | "dark";
394
+ }): string {
395
+ const params = new URLSearchParams();
396
+ params.set("origin", args.pageOrigin);
397
+ params.set("app", args.app);
398
+ params.set("redirect_to", args.redirectTo);
399
+ params.set("scheme", args.scheme);
400
+ if (this.project) params.set("project_id", this.project);
401
+ return this.origin + "/api/sdk/grant?" + params.toString();
402
+ }
403
+
404
+ // ── Internals ────────────────────────────────────────────────────
405
+
406
+ private nowSec() {
407
+ return Math.floor(Date.now() / 1000);
408
+ }
409
+
410
+ private tokenIsFresh(): boolean {
411
+ return !!this.token && this.nowSec() < this.expiresAt - TOKEN_REFRESH_MARGIN_S;
412
+ }
413
+
414
+ private inIframe(): boolean {
415
+ try {
416
+ return window.self !== window.top;
417
+ } catch {
418
+ return true;
419
+ }
420
+ }
421
+
422
+ private setToken(token: string, expiresAt: number, user?: BrowserUser) {
423
+ this.token = token;
424
+ this.expiresAt = expiresAt || this.nowSec() + 3600;
425
+ if (user) {
426
+ this.userInfo = user;
427
+ if (!this.hideBadge) this.renderBadge();
428
+ }
429
+ this.savePersisted();
430
+ for (const fn of this.listeners) {
431
+ try {
432
+ fn(token);
433
+ } catch (err) {
434
+ console.warn("[runapps] onTokenChange handler threw", err);
435
+ }
436
+ }
437
+ }
438
+
439
+ private savePersisted() {
440
+ if (typeof localStorage === "undefined" || !this.token) return;
441
+ try {
442
+ const data: PersistedAuth = {
443
+ token: this.token,
444
+ expiresAt: this.expiresAt,
445
+ origin: this.origin,
446
+ ...(this.userInfo ? { user: this.userInfo } : {}),
447
+ };
448
+ localStorage.setItem(storageKey(this.project), JSON.stringify(data));
449
+ } catch {
450
+ /* quota / private mode — degrade to in-memory only */
451
+ }
452
+ }
453
+
454
+ private loadPersisted() {
455
+ if (typeof localStorage === "undefined") return;
456
+ try {
457
+ const raw = localStorage.getItem(storageKey(this.project));
458
+ if (!raw) return;
459
+ const data = JSON.parse(raw) as Partial<PersistedAuth>;
460
+ if (!data.token || !data.origin) return;
461
+ // Pin to the gateway origin we were constructed with — a bundle
462
+ // talking to a different gateway shouldn't reuse this token.
463
+ if (data.origin !== this.origin) return;
464
+ // Already expired? Drop it; next call will redirect to grant.
465
+ if (typeof data.expiresAt === "number" && data.expiresAt > 0 &&
466
+ this.nowSec() >= data.expiresAt - TOKEN_REFRESH_MARGIN_S) {
467
+ this.clearPersisted();
468
+ return;
469
+ }
470
+ this.token = data.token;
471
+ this.expiresAt = data.expiresAt ?? 0;
472
+ if (data.user) this.userInfo = data.user;
473
+ } catch {
474
+ /* corrupted blob — drop it */
475
+ this.clearPersisted();
476
+ }
477
+ }
478
+
479
+ private clearPersisted() {
480
+ if (typeof localStorage === "undefined") return;
481
+ try {
482
+ localStorage.removeItem(storageKey(this.project));
483
+ } catch { /* ignore */ }
484
+ }
485
+
486
+ private isSignedOut(): boolean {
487
+ if (typeof localStorage === "undefined") return false;
488
+ try {
489
+ return localStorage.getItem(signedOutKey(this.project)) === "1";
490
+ } catch { return false; }
491
+ }
492
+
493
+ private markSignedOut() {
494
+ if (typeof localStorage === "undefined") return;
495
+ try {
496
+ localStorage.setItem(signedOutKey(this.project), "1");
497
+ } catch { /* ignore */ }
498
+ }
499
+
500
+ private clearSignedOut() {
501
+ if (typeof localStorage === "undefined") return;
502
+ try {
503
+ localStorage.removeItem(signedOutKey(this.project));
504
+ } catch { /* ignore */ }
505
+ }
506
+
507
+ private removeBadge() {
508
+ if (typeof document === "undefined") return;
509
+ document.getElementById("__runjobs_identity__")?.remove();
510
+ }
511
+
512
+ /** Parse the #runjobs_token=… fragment, install, then strip it. */
513
+ private consumeFragment(): boolean {
514
+ if (!location.hash || location.hash.indexOf("runjobs_token=") < 0)
515
+ return false;
516
+ const params: Record<string, string> = {};
517
+ location.hash
518
+ .replace(/^#/, "")
519
+ .split("&")
520
+ .forEach((kv) => {
521
+ const i = kv.indexOf("=");
522
+ if (i >= 0) params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
523
+ });
524
+ if (!params.runjobs_token) return false;
525
+ let user: BrowserUser | undefined;
526
+ if (params.user) {
527
+ try {
528
+ user = JSON.parse(params.user);
529
+ } catch {
530
+ /* malformed; ignore — token still works */
531
+ }
532
+ }
533
+ this.setToken(
534
+ params.runjobs_token,
535
+ parseInt(params.expires_at ?? "", 10) || 0,
536
+ user,
537
+ );
538
+ // Clean fragment so a refresh doesn't re-consume.
539
+ try {
540
+ history.replaceState(null, "", location.pathname + location.search);
541
+ } catch {
542
+ /* ignore — non-fatal */
543
+ }
544
+ return true;
545
+ }
546
+
547
+ /** postMessage handshake with the dashboard parent (iframe path). */
548
+ private requestTokenFromParent(): Promise<void> {
549
+ return new Promise((resolve, reject) => {
550
+ let done = false;
551
+ const timers: ReturnType<typeof setTimeout>[] = [];
552
+ const cleanup = () => {
553
+ done = true;
554
+ window.removeEventListener("message", onMessage);
555
+ for (const t of timers) clearTimeout(t);
556
+ };
557
+ const onMessage = (e: MessageEvent) => {
558
+ if (e.origin !== this.origin) return;
559
+ const data = e.data as
560
+ | { type: string; token?: string; expires_at?: number; user?: BrowserUser }
561
+ | null;
562
+ if (data && data.type === "runjobs:token" && data.token) {
563
+ this.setToken(data.token, data.expires_at ?? 0, data.user);
564
+ cleanup();
565
+ resolve();
566
+ }
567
+ };
568
+ window.addEventListener("message", onMessage);
569
+
570
+ // postMessage is NOT queued: if the parent's React listener isn't
571
+ // mounted at the instant of our first post, that single message is
572
+ // lost forever and we'd time out → dead-end grant redirect. So
573
+ // re-post a few times across the wait window; a late-mounting
574
+ // parent still receives a request to answer. The parent also
575
+ // pushes the token proactively on iframe load — belt-and-braces.
576
+ const post = () => {
577
+ if (done) return;
578
+ try {
579
+ window.parent.postMessage({ type: "runjobs:request-token" }, this.origin);
580
+ } catch (err) {
581
+ cleanup();
582
+ reject(err);
583
+ }
584
+ };
585
+ post();
586
+ for (const d of [250, 750, 1500, 3000]) timers.push(setTimeout(post, d));
587
+
588
+ // Longer overall window (5s vs the old 3s) gives a heavy bundle +
589
+ // a cold parent room to complete the round trip before we give up.
590
+ timers.push(
591
+ setTimeout(() => {
592
+ if (done) return;
593
+ cleanup();
594
+ reject(new Error("runapps: parent handshake timeout"));
595
+ }, 5000),
596
+ );
597
+ });
598
+ }
599
+
600
+ /** Floating identity badge — bottom-right pill showing the user.
601
+ * Click opens the runapps.ai dashboard in a new tab so the user
602
+ * can manage their account / billing without losing the bundle's
603
+ * in-flight state. Uses `noopener,noreferrer` so the new tab
604
+ * can't reach back into the bundle window via `window.opener`. */
605
+ private renderBadge() {
606
+ if (typeof document === "undefined" || !this.userInfo) return;
607
+ const ID = "__runjobs_identity__";
608
+ const dashboardUrl = `${this.origin}/dashboard`;
609
+ const ready = (cb: () => void) =>
610
+ document.body ? cb() : document.addEventListener("DOMContentLoaded", cb);
611
+ ready(() => {
612
+ // Idempotent: remove an older instance so successive renders
613
+ // (e.g. after sign-in finishes) don't stack badges.
614
+ document.getElementById(ID)?.remove();
615
+ const root = mountActivityBadge({
616
+ id: ID,
617
+ user: this.userInfo as BrowserUser,
618
+ dashboardUrl,
619
+ tracker: this.tracker,
620
+ position: this.badgePosition,
621
+ // Bind through `this` so the popover's Sign-out button runs
622
+ // BrowserAuth.signOut (clears token cache + flips signed-out
623
+ // flag + dispatches the auth event); a plain method reference
624
+ // would lose `this`.
625
+ //
626
+ // Then hard-reload the page. signOut() deliberately doesn't
627
+ // navigate (its contract leaves that to the host bundle), but
628
+ // the badge IS the SDK's own UI, and a user clicking its
629
+ // "Sign out" expects to land back on the app's signed-out /
630
+ // login view — not keep staring at a now-stale authed page.
631
+ // The reload re-runs the bundle's bootstrap against a cleared
632
+ // token + the sticky signed-out flag, so the app naturally
633
+ // shows its login state. Guard window for non-browser hosts.
634
+ onSignOut: () => {
635
+ this.signOut();
636
+ if (typeof window !== "undefined") window.location.reload();
637
+ },
638
+ });
639
+ document.body.appendChild(root);
640
+ });
641
+ }
642
+ }
643
+
644
+ /* ============================================================ *
645
+ * Activity Badge UI *
646
+ * ============================================================ *
647
+ *
648
+ * Vanilla-DOM render of the bottom-right identity badge with a
649
+ * desktop-ball-style real-time activity layer. Stays out of
650
+ * BrowserAuth's main class body to keep auth state and DOM concerns
651
+ * separate — auth owns "who is signed in", this owns "what's
652
+ * happening right now". Three visual layers:
653
+ *
654
+ * 1. Avatar + name pill — the existing static badge.
655
+ * 2. Progress ring (SVG) wrapping the avatar — animates speed
656
+ * proportional to tokens/sec when a stream is active; fades
657
+ * out at idle. Single in-flight call shows one rotating arc;
658
+ * multi-call shows the avatar's outline glowing.
659
+ * 3. LED dot at avatar's bottom-right — discrete colour signal:
660
+ * grey (idle) / blue pulsing (active) / red (recent error) /
661
+ * green fade (success-burst, 300ms after end).
662
+ *
663
+ * Clicking the badge toggles a popover showing the same data the
664
+ * spec called for: Active / Recent / Session. Hover with 400ms
665
+ * grace also opens it (matches macOS / Discord tray tooltips).
666
+ *
667
+ * Performance: the redraw loop pauses entirely when status==idle
668
+ * AND the popover is closed (no rAF burn). When active, it
669
+ * re-renders at ~6 Hz — fast enough for token rate to look live,
670
+ * cheap enough to not warm a laptop fan.
671
+ */
672
+
673
+ interface MountOpts {
674
+ id: string;
675
+ user: BrowserUser;
676
+ dashboardUrl: string;
677
+ tracker: ActivityTracker | null;
678
+ position: BadgePosition;
679
+ /** Fired when the user clicks the popover's "Sign out" link.
680
+ * Wires up to BrowserAuth.signOut so the token cache is cleared
681
+ * and the in-tab state flips to signed-out without a reload. */
682
+ onSignOut: () => void;
683
+ }
684
+
685
+ /**
686
+ * Per-corner CSS overrides for the floating badge + its popover.
687
+ * `top` vs `bottom` flips the badge's vertical anchor AND the
688
+ * popover's open direction (top-anchored badge → popover opens
689
+ * downward); `right` vs `left` flips horizontal anchor on both.
690
+ */
691
+ /**
692
+ * Inject the `@keyframes __runjobs_led_pulse__` rule into the document
693
+ * head ONCE (idempotent — re-mounts and multiple SDK instances on the
694
+ * same page share the same rule). Must run at badge mount time so
695
+ * the LED's `animation` CSS resolves the keyframe reference the
696
+ * moment status flips from idle to active — previously this lived
697
+ * inside createPopover() and only fired on first hover, leaving the
698
+ * LED frozen until the user interacted.
699
+ */
700
+ function ensurePulseKeyframes(): void {
701
+ if (typeof document === "undefined") return;
702
+ if (document.getElementById("__runjobs_kf__")) return;
703
+ const style = document.createElement("style");
704
+ style.id = "__runjobs_kf__";
705
+ style.textContent =
706
+ "@keyframes __runjobs_led_pulse__ {" +
707
+ " 0% { box-shadow: 0 0 0 0 rgba(59,130,246,0.7); }" +
708
+ " 70% { box-shadow: 0 0 0 6px rgba(59,130,246,0); }" +
709
+ " 100% { box-shadow: 0 0 0 0 rgba(59,130,246,0); }" +
710
+ "}";
711
+ (document.head ?? document.documentElement).appendChild(style);
712
+ }
713
+
714
+ function badgeCornerStyles(position: BadgePosition): {
715
+ badge: string;
716
+ popover: string;
717
+ } {
718
+ const v = position.startsWith("top") ? "top" : "bottom";
719
+ const h = position.endsWith("right") ? "right" : "left";
720
+ // Popover sits on the OPPOSITE vertical side of the badge so it
721
+ // grows AWAY from the edge of the viewport, never offscreen.
722
+ const popoverVerticalAnchor =
723
+ v === "bottom" ? "bottom:calc(100% + 8px);top:auto" : "top:calc(100% + 8px);bottom:auto";
724
+ const popoverHorizontalAnchor = h === "right" ? "right:0;left:auto" : "left:0;right:auto";
725
+ return {
726
+ badge: `${v}:16px;${h}:16px`,
727
+ popover: `${popoverVerticalAnchor};${popoverHorizontalAnchor}`,
728
+ };
729
+ }
730
+
731
+ // ─── Drag-to-move + position persistence ────────────────────────────
732
+ //
733
+ // Users sometimes want to move the floating badge — it overlaps a
734
+ // site's own corner UI (chat widget, footer button, etc.). Drag to
735
+ // reposition; the saved coords land in a cookie so reloads keep the
736
+ // chosen spot. Cookie because the user asked for cookie; localStorage
737
+ // would be the more typical choice for ~10 bytes of UI state, but
738
+ // cookie is fine — small payload, same-site, no SDK dependency on a
739
+ // storage permission.
740
+
741
+ /** Cookie key the saved badge position lives under. URI-safe, prefixed
742
+ * so other libraries / consumers don't accidentally collide. */
743
+ const BADGE_POS_COOKIE = "__runjobs_badge_pos__";
744
+
745
+ /** Pixel threshold a pointer must travel before we treat the gesture
746
+ * as a drag instead of a click. Below this, it's a normal badge tap
747
+ * that opens the popover. 5 px matches OS-level click slop tolerances. */
748
+ const BADGE_DRAG_THRESHOLD_PX = 5;
749
+
750
+ /** Edge inset when clamping a dragged badge into the viewport — keeps
751
+ * it visually inside the safe area on resize. Match the default
752
+ * corner offset (`16px` in badgeCornerStyles). */
753
+ const BADGE_EDGE_INSET_PX = 8;
754
+
755
+ function readBadgePosCookie(): { x: number; y: number } | null {
756
+ if (typeof document === "undefined") return null;
757
+ const raw = document.cookie
758
+ .split(";")
759
+ .map((s) => s.trim())
760
+ .find((s) => s.startsWith(BADGE_POS_COOKIE + "="));
761
+ if (!raw) return null;
762
+ const value = decodeURIComponent(raw.slice(BADGE_POS_COOKIE.length + 1));
763
+ const parts = value.split(",");
764
+ const rawX = parts[0];
765
+ const rawY = parts[1];
766
+ if (parts.length !== 2 || rawX === undefined || rawY === undefined) return null;
767
+ const x = Number.parseInt(rawX, 10);
768
+ const y = Number.parseInt(rawY, 10);
769
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
770
+ return { x, y };
771
+ }
772
+
773
+ function writeBadgePosCookie(x: number, y: number): void {
774
+ if (typeof document === "undefined") return;
775
+ // 1-year expiry, root path, lax cross-site so the cookie survives
776
+ // navigation between subdomains under the same site. Secure when
777
+ // we're on HTTPS so iframes can read it back without warnings.
778
+ const maxAge = 60 * 60 * 24 * 365;
779
+ const secure = typeof window !== "undefined" && window.location?.protocol === "https:" ? ";Secure" : "";
780
+ document.cookie =
781
+ `${BADGE_POS_COOKIE}=${encodeURIComponent(`${Math.round(x)},${Math.round(y)}`)};` +
782
+ `path=/;max-age=${maxAge};SameSite=Lax${secure}`;
783
+ }
784
+
785
+ /** Snap (x, y) into the viewport so a saved position from a wider
786
+ * screen doesn't render off-canvas after a window resize.
787
+ * Coordinates are top-left of the badge element; w/h are its bounds. */
788
+ function clampToViewport(
789
+ x: number,
790
+ y: number,
791
+ w: number,
792
+ h: number,
793
+ ): { x: number; y: number } {
794
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
795
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
796
+ const maxX = Math.max(BADGE_EDGE_INSET_PX, vw - w - BADGE_EDGE_INSET_PX);
797
+ const maxY = Math.max(BADGE_EDGE_INSET_PX, vh - h - BADGE_EDGE_INSET_PX);
798
+ return {
799
+ x: Math.min(Math.max(BADGE_EDGE_INSET_PX, x), maxX),
800
+ y: Math.min(Math.max(BADGE_EDGE_INSET_PX, y), maxY),
801
+ };
802
+ }
803
+
804
+ /** Recompute the popover anchor CSS based on which quadrant of the
805
+ * viewport the badge currently sits in. Mirrors badgeCornerStyles'
806
+ * rule: popover grows AWAY from the nearer edge so it never escapes
807
+ * the viewport. Used after a drag so the popover keeps opening
808
+ * inward even when the badge is no longer in its starting corner. */
809
+ function popoverCssForRect(rect: { left: number; top: number; width: number; height: number }): string {
810
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
811
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
812
+ const cx = rect.left + rect.width / 2;
813
+ const cy = rect.top + rect.height / 2;
814
+ const verticalAnchor =
815
+ cy < vh / 2
816
+ ? "top:calc(100% + 8px);bottom:auto"
817
+ : "bottom:calc(100% + 8px);top:auto";
818
+ const horizontalAnchor = cx < vw / 2 ? "left:0;right:auto" : "right:0;left:auto";
819
+ return `${verticalAnchor};${horizontalAnchor}`;
820
+ }
821
+
822
+ function mountActivityBadge(opts: MountOpts): HTMLButtonElement {
823
+ const { id, user, dashboardUrl, tracker, position, onSignOut } = opts;
824
+ const corners = badgeCornerStyles(position);
825
+
826
+ // `currentPopoverCss` starts at the corner-derived anchor and gets
827
+ // overwritten when the user drags the badge to a new quadrant.
828
+ // createPopover() reads this at popover instantiation time; if the
829
+ // popover was already built before a drag, the drag handler also
830
+ // patches popover.style directly so the next open uses the new anchor.
831
+ let currentPopoverCss = corners.popover;
832
+ // Inject the LED pulse keyframes at mount time. Previously this
833
+ // lived inside createPopover() — which only runs on first hover —
834
+ // so the LED's `animation` CSS referenced a keyframe rule that
835
+ // didn't exist until the user hovered, and the browser silently
836
+ // skipped the animation. Result: LED stayed solid until hover,
837
+ // exactly matching the bug report.
838
+ ensurePulseKeyframes();
839
+ const el = document.createElement("button");
840
+ el.id = id;
841
+ el.type = "button";
842
+ el.title = "RunApps activity";
843
+ el.setAttribute("aria-label", "RunApps activity status");
844
+ el.style.cssText = [
845
+ "position:fixed",
846
+ corners.badge,
847
+ "z-index:2147483647",
848
+ "display:flex",
849
+ "align-items:center",
850
+ "gap:8px",
851
+ "padding:6px 12px 6px 6px",
852
+ "border-radius:999px",
853
+ "font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif",
854
+ "color:#fff",
855
+ "background:rgba(15,15,20,0.55)",
856
+ "backdrop-filter:blur(10px) saturate(1.2)",
857
+ "-webkit-backdrop-filter:blur(10px) saturate(1.2)",
858
+ "border:1px solid rgba(255,255,255,0.12)",
859
+ "box-shadow:0 4px 16px rgba(0,0,0,0.15)",
860
+ "user-select:none",
861
+ "max-width:220px",
862
+ "overflow:visible", // popover spills out the top
863
+ "cursor:pointer",
864
+ "appearance:none",
865
+ "-webkit-appearance:none",
866
+ "transition:background-color 120ms ease-out, box-shadow 120ms ease-out",
867
+ ].join(";");
868
+ el.addEventListener("mouseenter", () => {
869
+ el.style.backgroundColor = "rgba(30,30,40,0.7)";
870
+ el.style.boxShadow = "0 6px 20px rgba(0,0,0,0.22)";
871
+ });
872
+ el.addEventListener("mouseleave", () => {
873
+ el.style.backgroundColor = "rgba(15,15,20,0.55)";
874
+ el.style.boxShadow = "0 4px 16px rgba(0,0,0,0.15)";
875
+ });
876
+
877
+ // ─── Avatar with ring + LED ────────────────────────────────
878
+ const avatarWrap = document.createElement("span");
879
+ avatarWrap.style.cssText = [
880
+ "position:relative",
881
+ "width:26px;height:26px",
882
+ "flex-shrink:0",
883
+ "display:inline-flex;align-items:center;justify-content:center",
884
+ ].join(";");
885
+
886
+ // Progress ring (SVG). Hidden at idle so we don't paint when
887
+ // nothing's happening. Stroke offset / dasharray animate via
888
+ // direct style writes from the redraw loop.
889
+ const ringNS = "http://www.w3.org/2000/svg";
890
+ const ring = document.createElementNS(ringNS, "svg");
891
+ ring.setAttribute("viewBox", "0 0 26 26");
892
+ ring.setAttribute("width", "26");
893
+ ring.setAttribute("height", "26");
894
+ ring.style.cssText = [
895
+ "position:absolute;inset:0",
896
+ "pointer-events:none",
897
+ "opacity:0",
898
+ "transition:opacity 200ms ease-out",
899
+ ].join(";");
900
+ const ringTrack = document.createElementNS(ringNS, "circle");
901
+ ringTrack.setAttribute("cx", "13");
902
+ ringTrack.setAttribute("cy", "13");
903
+ ringTrack.setAttribute("r", "12");
904
+ ringTrack.setAttribute("fill", "none");
905
+ ringTrack.setAttribute("stroke", "rgba(255,255,255,0.08)");
906
+ ringTrack.setAttribute("stroke-width", "1.5");
907
+ const ringArc = document.createElementNS(ringNS, "circle");
908
+ ringArc.setAttribute("cx", "13");
909
+ ringArc.setAttribute("cy", "13");
910
+ ringArc.setAttribute("r", "12");
911
+ ringArc.setAttribute("fill", "none");
912
+ ringArc.setAttribute("stroke", "#3b82f6");
913
+ ringArc.setAttribute("stroke-width", "2");
914
+ ringArc.setAttribute("stroke-linecap", "round");
915
+ // Rotate origin = center, start arc from 12 o'clock so progress
916
+ // sweeps clockwise like every progress UI on the planet.
917
+ ringArc.setAttribute("transform", "rotate(-90 13 13)");
918
+ // Circumference = 2πr ≈ 75.4. dasharray=circumference + dashoffset
919
+ // drives the arc length.
920
+ const RING_CIRC = 2 * Math.PI * 12;
921
+ ringArc.setAttribute("stroke-dasharray", String(RING_CIRC));
922
+ ringArc.setAttribute("stroke-dashoffset", String(RING_CIRC));
923
+ ring.appendChild(ringTrack);
924
+ ring.appendChild(ringArc);
925
+
926
+ const avatar = document.createElement("div");
927
+ avatar.textContent = (user.name || "?").charAt(0).toUpperCase();
928
+ avatar.style.cssText = [
929
+ "width:22px;height:22px;border-radius:50%;flex-shrink:0",
930
+ "display:flex;align-items:center;justify-content:center",
931
+ "background:linear-gradient(135deg,#3b82f6,#8b5cf6)",
932
+ "font-size:11px;font-weight:600;color:#fff",
933
+ "position:relative",
934
+ ].join(";");
935
+
936
+ // LED status dot — absolute-positioned over the avatar's
937
+ // bottom-right corner. Same trick Discord uses for online status.
938
+ const led = document.createElement("span");
939
+ led.style.cssText = [
940
+ "position:absolute",
941
+ "bottom:-2px;right:-2px",
942
+ "width:8px;height:8px;border-radius:50%",
943
+ "background:rgba(255,255,255,0.2)",
944
+ "border:2px solid rgba(15,15,20,1)",
945
+ "box-shadow:0 0 0 0 transparent",
946
+ "transition:background-color 150ms ease-out, box-shadow 200ms ease-out",
947
+ "pointer-events:none",
948
+ ].join(";");
949
+
950
+ avatarWrap.appendChild(ring);
951
+ avatarWrap.appendChild(avatar);
952
+ avatarWrap.appendChild(led);
953
+
954
+ const name = document.createElement("span");
955
+ name.textContent = user.name || "Signed in";
956
+ name.style.cssText = "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none";
957
+
958
+ el.appendChild(avatarWrap);
959
+ el.appendChild(name);
960
+
961
+ // ─── Popover (lazy, append on first open) ──────────────────
962
+ //
963
+ // Open / close has two modes:
964
+ // - hover: 400ms-grace open on badge enter; 300ms-grace close
965
+ // when neither badge NOR popover is hovered. The grace lets
966
+ // the user cross the 8px gap between badge and popover without
967
+ // losing the popover. Standard tooltip pattern.
968
+ // - click: sticky — stays open until the user clicks outside.
969
+ // Useful for reading + copying values out of the panel.
970
+ let popover: HTMLDivElement | null = null;
971
+ let popoverOpen = false;
972
+ let popoverOpenedBy: "hover" | "click" | null = null;
973
+ let openTimer: number | null = null;
974
+ let closeTimer: number | null = null;
975
+
976
+ const cancelOpen = () => {
977
+ if (openTimer !== null) {
978
+ window.clearTimeout(openTimer);
979
+ openTimer = null;
980
+ }
981
+ };
982
+ const cancelClose = () => {
983
+ if (closeTimer !== null) {
984
+ window.clearTimeout(closeTimer);
985
+ closeTimer = null;
986
+ }
987
+ };
988
+ const scheduleClose = () => {
989
+ cancelClose();
990
+ closeTimer = window.setTimeout(() => {
991
+ // Re-check the mode — a click between schedule and fire turns
992
+ // a hover-pending close into a sticky open.
993
+ if (popoverOpenedBy === "hover") closePopover();
994
+ }, 300);
995
+ };
996
+
997
+ const openPopover = (by: "hover" | "click") => {
998
+ cancelOpen();
999
+ cancelClose();
1000
+ if (popoverOpen) {
1001
+ // Upgrade a hover-open to click-stickiness if the user
1002
+ // clicked while the popover was already hover-shown.
1003
+ if (by === "click") popoverOpenedBy = "click";
1004
+ return;
1005
+ }
1006
+ if (!popover) {
1007
+ popover = createPopover(dashboardUrl, currentPopoverCss, () => {
1008
+ // Tear down our DOM before the auth flip runs so the popover
1009
+ // doesn't briefly show "signed out" state to a user who's
1010
+ // about to navigate away. closePopover() also removes the
1011
+ // hover/click handlers' state.
1012
+ closePopover();
1013
+ onSignOut();
1014
+ });
1015
+ // Attach hover handlers ONCE — the popover element is reused
1016
+ // across open/close cycles, so .remove() / .appendChild()
1017
+ // doesn't shake the listeners off.
1018
+ popover.addEventListener("mouseenter", cancelClose);
1019
+ popover.addEventListener("mouseleave", () => {
1020
+ if (popoverOpenedBy === "hover") scheduleClose();
1021
+ });
1022
+ }
1023
+ el.appendChild(popover);
1024
+ popoverOpen = true;
1025
+ popoverOpenedBy = by;
1026
+ // Schedule one immediate redraw so the popover shows current
1027
+ // state, not a stale snapshot from the last close.
1028
+ requestRedraw();
1029
+ };
1030
+
1031
+ const closePopover = () => {
1032
+ cancelClose();
1033
+ if (!popoverOpen) return;
1034
+ popover?.remove();
1035
+ popoverOpen = false;
1036
+ popoverOpenedBy = null;
1037
+ };
1038
+
1039
+ el.addEventListener("click", (e) => {
1040
+ e.stopPropagation();
1041
+ popoverOpen ? closePopover() : openPopover("click");
1042
+ });
1043
+ // Hover-open with 400ms delay — matches macOS tray tooltips.
1044
+ el.addEventListener("mouseenter", () => {
1045
+ cancelClose(); // moving back onto badge cancels a pending close
1046
+ cancelOpen();
1047
+ openTimer = window.setTimeout(() => {
1048
+ if (!popoverOpen) openPopover("hover");
1049
+ }, 400);
1050
+ });
1051
+ el.addEventListener("mouseleave", () => {
1052
+ cancelOpen();
1053
+ // Only the hover branch auto-closes; click-opened stays sticky
1054
+ // and dismisses via the click-outside handler below.
1055
+ if (popoverOpenedBy === "hover") scheduleClose();
1056
+ });
1057
+ // Click anywhere outside the badge closes the popover.
1058
+ document.addEventListener("click", (e) => {
1059
+ if (popoverOpen && !el.contains(e.target as Node)) closePopover();
1060
+ });
1061
+
1062
+ // ─── Drag-to-move + cookie persistence ─────────────────────
1063
+ //
1064
+ // Two intents share the pointer-down on the badge:
1065
+ // 1. Quick tap → open popover (existing click handler).
1066
+ // 2. Hold-and-drag → reposition the badge.
1067
+ //
1068
+ // Disambiguate by movement distance: until the pointer travels
1069
+ // BADGE_DRAG_THRESHOLD_PX from its starting point we treat the
1070
+ // gesture as a pending click and let the existing click handler
1071
+ // run on pointerup. Past that threshold we flip to drag mode —
1072
+ // start moving the badge, claim the pointer via setPointerCapture
1073
+ // so we keep getting moves even when the pointer leaves the badge
1074
+ // bounds, and on pointerup persist the final coords + suppress
1075
+ // the click event the browser would otherwise synthesize.
1076
+ let dragStartClientX = 0;
1077
+ let dragStartClientY = 0;
1078
+ let dragStartBadgeLeft = 0;
1079
+ let dragStartBadgeTop = 0;
1080
+ let pointerDown = false;
1081
+ let isDragging = false;
1082
+ let suppressNextClick = false;
1083
+
1084
+ /** Apply absolute pixel position to the badge, clearing the corner
1085
+ * anchors set at mount. Used both by the drag handler (live update)
1086
+ * and the cookie-restore path (mount-time hydrate). */
1087
+ const applyAbsolutePos = (x: number, y: number) => {
1088
+ el.style.left = `${x}px`;
1089
+ el.style.top = `${y}px`;
1090
+ el.style.right = "auto";
1091
+ el.style.bottom = "auto";
1092
+ };
1093
+
1094
+ /** Recompute + apply the popover anchor based on the badge's current
1095
+ * position in the viewport. Called after a drag so the popover
1096
+ * always grows away from the nearest viewport edge. If the popover
1097
+ * was already instantiated, patches its inline style in place. */
1098
+ const recomputePopoverAnchor = () => {
1099
+ const r = el.getBoundingClientRect();
1100
+ currentPopoverCss = popoverCssForRect({
1101
+ left: r.left,
1102
+ top: r.top,
1103
+ width: r.width,
1104
+ height: r.height,
1105
+ });
1106
+ if (popover) {
1107
+ // Reset the four anchor properties before re-applying so we
1108
+ // don't leave stale `right` / `bottom` declarations stuck on
1109
+ // the element from the previous anchor.
1110
+ popover.style.top = "";
1111
+ popover.style.bottom = "";
1112
+ popover.style.left = "";
1113
+ popover.style.right = "";
1114
+ for (const decl of currentPopoverCss.split(";")) {
1115
+ const [prop, val] = decl.split(":");
1116
+ if (prop && val) popover.style.setProperty(prop.trim(), val.trim());
1117
+ }
1118
+ }
1119
+ };
1120
+
1121
+ el.addEventListener("pointerdown", (e) => {
1122
+ // Only the primary button drags — secondary clicks should fall
1123
+ // through (browsers may show a context menu, etc.).
1124
+ if (e.button !== 0) return;
1125
+ pointerDown = true;
1126
+ isDragging = false;
1127
+ dragStartClientX = e.clientX;
1128
+ dragStartClientY = e.clientY;
1129
+ const r = el.getBoundingClientRect();
1130
+ dragStartBadgeLeft = r.left;
1131
+ dragStartBadgeTop = r.top;
1132
+ });
1133
+
1134
+ el.addEventListener("pointermove", (e) => {
1135
+ if (!pointerDown) return;
1136
+ const dx = e.clientX - dragStartClientX;
1137
+ const dy = e.clientY - dragStartClientY;
1138
+ if (!isDragging) {
1139
+ if (Math.abs(dx) < BADGE_DRAG_THRESHOLD_PX && Math.abs(dy) < BADGE_DRAG_THRESHOLD_PX) {
1140
+ return; // still within click slop — keep treating as a tap
1141
+ }
1142
+ // Cross the threshold → enter drag mode. Capture the pointer
1143
+ // so further moves arrive even when the pointer escapes the
1144
+ // badge bounds (fast drags leave the small pill quickly).
1145
+ isDragging = true;
1146
+ el.setPointerCapture(e.pointerId);
1147
+ el.style.cursor = "grabbing";
1148
+ // Pre-emptively close any open popover — keeping it pinned to a
1149
+ // moving badge looks janky and a drag is clearly a different
1150
+ // intent than "look at the popover".
1151
+ if (popoverOpen) closePopover();
1152
+ }
1153
+ const rect = el.getBoundingClientRect();
1154
+ const clamped = clampToViewport(
1155
+ dragStartBadgeLeft + dx,
1156
+ dragStartBadgeTop + dy,
1157
+ rect.width,
1158
+ rect.height,
1159
+ );
1160
+ applyAbsolutePos(clamped.x, clamped.y);
1161
+ });
1162
+
1163
+ const endDrag = (e: PointerEvent) => {
1164
+ if (!pointerDown) return;
1165
+ pointerDown = false;
1166
+ if (!isDragging) return;
1167
+ isDragging = false;
1168
+ el.style.cursor = "pointer";
1169
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
1170
+ // Persist final position. Re-read the rect so what we store is
1171
+ // exactly what's on screen — clampToViewport may have shaved
1172
+ // the trailing few pixels.
1173
+ const r = el.getBoundingClientRect();
1174
+ writeBadgePosCookie(r.left, r.top);
1175
+ recomputePopoverAnchor();
1176
+ // The browser will synthesize a click on the badge from this
1177
+ // pointer sequence (pointer went down + up on the same element).
1178
+ // Swallow it so a drag doesn't immediately open the popover.
1179
+ suppressNextClick = true;
1180
+ };
1181
+ el.addEventListener("pointerup", endDrag);
1182
+ el.addEventListener("pointercancel", endDrag);
1183
+
1184
+ // Capture-phase click swallower — runs BEFORE the existing click
1185
+ // handler that toggles the popover, so we can stop a drag-tail
1186
+ // click without modifying the open/close logic itself.
1187
+ el.addEventListener(
1188
+ "click",
1189
+ (e) => {
1190
+ if (suppressNextClick) {
1191
+ suppressNextClick = false;
1192
+ e.stopPropagation();
1193
+ e.preventDefault();
1194
+ }
1195
+ },
1196
+ true,
1197
+ );
1198
+
1199
+ // Hydrate from cookie. After all the listeners and the corner-based
1200
+ // default style are in place, fast-forward to the user's last saved
1201
+ // position if any. Clamp to the current viewport so a saved spot
1202
+ // from a wider monitor doesn't put the badge off-canvas.
1203
+ const savedPos = readBadgePosCookie();
1204
+ if (savedPos) {
1205
+ // We need el's measured size to clamp; but el isn't in the DOM
1206
+ // yet, so we lean on a defer to next frame after first append.
1207
+ // Until then, apply the raw saved coords (the corner-default
1208
+ // styles still apply if these are absurd; clamp will fix it).
1209
+ applyAbsolutePos(savedPos.x, savedPos.y);
1210
+ // Queue a one-shot post-mount clamp + popover-anchor recompute.
1211
+ // requestAnimationFrame fires after the parent appendChild
1212
+ // brings the badge into layout, so getBoundingClientRect works.
1213
+ requestAnimationFrame(() => {
1214
+ const r = el.getBoundingClientRect();
1215
+ const clamped = clampToViewport(savedPos.x, savedPos.y, r.width, r.height);
1216
+ applyAbsolutePos(clamped.x, clamped.y);
1217
+ recomputePopoverAnchor();
1218
+ });
1219
+ }
1220
+
1221
+ // ─── Redraw loop ───────────────────────────────────────────
1222
+ //
1223
+ // Idle: zero work. Active OR popover open: redraw at ~6 Hz so
1224
+ // the ring sweep and per-call elapsed counters stay live without
1225
+ // burning CPU. Token-arrival events also push a redraw out of
1226
+ // band so the ring jumps as soon as a chunk lands.
1227
+ let rafScheduled = false;
1228
+ let intervalHandle: number | null = null;
1229
+
1230
+ const reconcile = () => {
1231
+ rafScheduled = false;
1232
+ if (!tracker) {
1233
+ // No event bus → static badge. Hide ring + LED.
1234
+ ring.style.opacity = "0";
1235
+ led.style.background = "rgba(255,255,255,0.2)";
1236
+ return;
1237
+ }
1238
+ const snap = tracker.snapshot();
1239
+ updateRing(ring, ringArc, RING_CIRC, snap);
1240
+ updateLED(led, snap);
1241
+ if (popoverOpen && popover) updatePopover(popover, snap);
1242
+ // Schedule the next paint only if there's reason. When the
1243
+ // popover is open we want the elapsed seconds to tick; when
1244
+ // active we want the ring to keep sweeping.
1245
+ const needsLoop = snap.status !== "idle" || popoverOpen;
1246
+ if (needsLoop && intervalHandle === null) {
1247
+ intervalHandle = window.setInterval(requestRedraw, 160);
1248
+ } else if (!needsLoop && intervalHandle !== null) {
1249
+ window.clearInterval(intervalHandle);
1250
+ intervalHandle = null;
1251
+ }
1252
+ };
1253
+
1254
+ function requestRedraw() {
1255
+ if (rafScheduled) return;
1256
+ rafScheduled = true;
1257
+ if (typeof requestAnimationFrame === "function") {
1258
+ requestAnimationFrame(reconcile);
1259
+ } else {
1260
+ setTimeout(reconcile, 0);
1261
+ }
1262
+ }
1263
+
1264
+ // Drive redraws off the tracker's onChange — fires synchronously
1265
+ // after every start / delta / end / error so the LED + ring update
1266
+ // within an animation frame of the event, not when the user
1267
+ // happens to hover the badge. requestRedraw() coalesces multiple
1268
+ // notifications into one rAF paint, so a chunky stream (30+ deltas
1269
+ // per second) still paints at most once per frame.
1270
+ if (tracker) {
1271
+ tracker.onChange(requestRedraw);
1272
+ }
1273
+
1274
+ // Initial paint so the user sees their LED/ring state at mount
1275
+ // even before any event fires.
1276
+ requestRedraw();
1277
+
1278
+ return el;
1279
+ }
1280
+
1281
+ function updateRing(
1282
+ ring: SVGSVGElement,
1283
+ arc: SVGCircleElement,
1284
+ circ: number,
1285
+ snap: ActivitySnapshot,
1286
+ ): void {
1287
+ if (snap.active.length === 0) {
1288
+ ring.style.opacity = "0";
1289
+ return;
1290
+ }
1291
+ ring.style.opacity = "1";
1292
+ // Pick the most informative active call for the ring (highest
1293
+ // tokens/sec, falling back to oldest). The popover lists every
1294
+ // call individually; this is just for the at-a-glance glow.
1295
+ const primary = snap.active.reduce((best, c) => (c.tokensPerSec > best.tokensPerSec ? c : best), snap.active[0]!);
1296
+ if (primary.streaming && primary.tokensPerSec > 0) {
1297
+ // Active streaming → arc length scales with tokens/sec, capped
1298
+ // at the full ring at ~60 tok/s (typical chat ceiling).
1299
+ const fraction = Math.min(primary.tokensPerSec / 60, 1);
1300
+ arc.setAttribute("stroke-dashoffset", String(circ * (1 - fraction)));
1301
+ arc.setAttribute("stroke", "#3b82f6");
1302
+ } else if (snap.status === "error") {
1303
+ arc.setAttribute("stroke-dashoffset", "0");
1304
+ arc.setAttribute("stroke", "#ef4444");
1305
+ } else {
1306
+ // Non-streaming or pre-first-token: indeterminate sweep — just
1307
+ // show a quarter-arc that rotates via CSS keyframes (added
1308
+ // below as a one-shot class).
1309
+ arc.setAttribute("stroke-dashoffset", String(circ * 0.75));
1310
+ arc.setAttribute("stroke", "#a78bfa");
1311
+ }
1312
+ }
1313
+
1314
+ function updateLED(led: HTMLSpanElement, snap: ActivitySnapshot): void {
1315
+ switch (snap.status) {
1316
+ case "idle":
1317
+ led.style.background = "rgba(255,255,255,0.2)";
1318
+ led.style.boxShadow = "0 0 0 0 transparent";
1319
+ break;
1320
+ case "active":
1321
+ led.style.background = "#3b82f6";
1322
+ // CSS-driven pulse via box-shadow ring expanding outward —
1323
+ // single keyframe loop, cheaper than animating opacity.
1324
+ led.style.boxShadow = "0 0 0 0 rgba(59,130,246,0.7)";
1325
+ led.style.animation = "__runjobs_led_pulse__ 1.2s ease-out infinite";
1326
+ break;
1327
+ case "error":
1328
+ led.style.background = "#ef4444";
1329
+ led.style.boxShadow = "0 0 0 2px rgba(239,68,68,0.3)";
1330
+ led.style.animation = "";
1331
+ break;
1332
+ }
1333
+ }
1334
+
1335
+ function createPopover(dashboardUrl: string, anchorCss: string, onSignOut: () => void): HTMLDivElement {
1336
+ const pop = document.createElement("div");
1337
+ pop.style.cssText = [
1338
+ "position:absolute",
1339
+ // Anchor varies by badge corner — see badgeCornerStyles. Splits
1340
+ // top/bottom + left/right so the popover always grows TOWARDS
1341
+ // the viewport interior.
1342
+ anchorCss,
1343
+ "min-width:280px;max-width:340px",
1344
+ "padding:10px 12px",
1345
+ "border-radius:10px",
1346
+ "background:rgba(15,15,20,0.92)",
1347
+ "backdrop-filter:blur(20px) saturate(1.3)",
1348
+ "-webkit-backdrop-filter:blur(20px) saturate(1.3)",
1349
+ "border:1px solid rgba(255,255,255,0.12)",
1350
+ "box-shadow:0 10px 30px rgba(0,0,0,0.4)",
1351
+ "color:#fff",
1352
+ "font:400 11px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif",
1353
+ "cursor:default",
1354
+ "text-align:left",
1355
+ ].join(";");
1356
+ pop.setAttribute("data-runjobs-popover", "1");
1357
+ pop.addEventListener("click", (e) => e.stopPropagation());
1358
+
1359
+ // The popover's content is rebuilt on every redraw to keep DOM
1360
+ // diffing simple. ~3 sections × ~6 children = ~20 nodes, no
1361
+ // measurable cost at the 6 Hz redraw rate.
1362
+ const inner = document.createElement("div");
1363
+ inner.setAttribute("data-runjobs-popover-content", "1");
1364
+ pop.appendChild(inner);
1365
+
1366
+ const footer = document.createElement("div");
1367
+ footer.style.cssText = [
1368
+ "margin-top:10px;padding-top:8px",
1369
+ "border-top:1px solid rgba(255,255,255,0.08)",
1370
+ "display:flex;justify-content:space-between;align-items:center",
1371
+ "font-size:10px;color:rgba(255,255,255,0.6)",
1372
+ ].join(";");
1373
+ const dash = document.createElement("a");
1374
+ dash.textContent = "Open dashboard →";
1375
+ dash.href = dashboardUrl;
1376
+ dash.target = "_blank";
1377
+ dash.rel = "noopener noreferrer";
1378
+ dash.style.cssText = "color:#a78bfa;text-decoration:none";
1379
+ footer.appendChild(dash);
1380
+
1381
+ // Sign-out lives opposite the dashboard link. Styled subtle on
1382
+ // purpose — it's a destructive-ish action (drops the token cache,
1383
+ // forces the next API call into the signIn redirect) but not
1384
+ // dangerous, and we don't want it to compete visually with the
1385
+ // dashboard CTA. Hover highlights it red as a "this is the action
1386
+ // that ends your session" cue.
1387
+ const signOutBtn = document.createElement("button");
1388
+ signOutBtn.type = "button";
1389
+ signOutBtn.textContent = "Sign out";
1390
+ signOutBtn.style.cssText = [
1391
+ "background:none;border:0;padding:0;margin:0",
1392
+ "font:inherit;cursor:pointer",
1393
+ "color:rgba(255,255,255,0.55)",
1394
+ "transition:color 120ms ease",
1395
+ ].join(";");
1396
+ signOutBtn.addEventListener("mouseenter", () => {
1397
+ signOutBtn.style.color = "#f87171";
1398
+ });
1399
+ signOutBtn.addEventListener("mouseleave", () => {
1400
+ signOutBtn.style.color = "rgba(255,255,255,0.55)";
1401
+ });
1402
+ signOutBtn.addEventListener("click", (e) => {
1403
+ e.preventDefault();
1404
+ e.stopPropagation();
1405
+ onSignOut();
1406
+ });
1407
+ footer.appendChild(signOutBtn);
1408
+
1409
+ pop.appendChild(footer);
1410
+ return pop;
1411
+ }
1412
+
1413
+ function updatePopover(pop: HTMLDivElement, snap: ActivitySnapshot): void {
1414
+ const inner = pop.querySelector('[data-runjobs-popover-content]') as HTMLDivElement | null;
1415
+ if (!inner) return;
1416
+ inner.innerHTML = "";
1417
+ const now = Date.now();
1418
+ inner.appendChild(renderActiveSection(snap, now));
1419
+ inner.appendChild(renderRecentSection(snap, now));
1420
+ inner.appendChild(renderSessionSection(snap.session, now));
1421
+ }
1422
+
1423
+ function renderActiveSection(snap: ActivitySnapshot, now: number): HTMLDivElement {
1424
+ const sec = document.createElement("div");
1425
+ sec.style.marginBottom = "10px";
1426
+ sec.appendChild(sectionLabel("Active", snap.active.length > 0 ? `${snap.active.length} in flight` : "—"));
1427
+ if (snap.active.length === 0) {
1428
+ const empty = document.createElement("div");
1429
+ empty.style.cssText = "color:rgba(255,255,255,0.4);font-size:10px;padding:2px 0";
1430
+ empty.textContent = "No calls in flight.";
1431
+ sec.appendChild(empty);
1432
+ return sec;
1433
+ }
1434
+ for (const c of snap.active) {
1435
+ const row = document.createElement("div");
1436
+ row.style.cssText = "display:flex;justify-content:space-between;gap:8px;padding:3px 0";
1437
+ const left = document.createElement("span");
1438
+ left.style.cssText = "color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px";
1439
+ left.textContent = c.model;
1440
+ const right = document.createElement("span");
1441
+ right.style.cssText = "color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums;font-size:10px";
1442
+ const elapsed = ((now - c.startedAt) / 1000).toFixed(1);
1443
+ const rate = c.tokensPerSec > 0 ? ` · ${Math.round(c.tokensPerSec)} tok/s` : "";
1444
+ right.textContent = `${elapsed}s${rate}`;
1445
+ row.appendChild(left);
1446
+ row.appendChild(right);
1447
+ sec.appendChild(row);
1448
+ }
1449
+ return sec;
1450
+ }
1451
+
1452
+ function renderRecentSection(snap: ActivitySnapshot, now: number): HTMLDivElement {
1453
+ const sec = document.createElement("div");
1454
+ sec.style.marginBottom = "10px";
1455
+ sec.appendChild(sectionLabel("Recent", snap.recent.length > 0 ? `last ${Math.min(snap.recent.length, 5)}` : "—"));
1456
+ if (snap.recent.length === 0) {
1457
+ const empty = document.createElement("div");
1458
+ empty.style.cssText = "color:rgba(255,255,255,0.4);font-size:10px;padding:2px 0";
1459
+ empty.textContent = "Nothing yet this session.";
1460
+ sec.appendChild(empty);
1461
+ return sec;
1462
+ }
1463
+ for (const c of snap.recent.slice(0, 5)) {
1464
+ const row = document.createElement("div");
1465
+ row.style.cssText = "display:flex;justify-content:space-between;gap:8px;padding:3px 0";
1466
+ const left = document.createElement("span");
1467
+ left.style.cssText = "color:#fff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px";
1468
+ // Prefix bullet by status — cheap visual scan.
1469
+ const bullet = document.createElement("span");
1470
+ bullet.textContent = c.ok ? "✓ " : "✗ ";
1471
+ bullet.style.color = c.ok ? "#10b981" : "#ef4444";
1472
+ left.appendChild(bullet);
1473
+ left.appendChild(document.createTextNode(c.model));
1474
+ const right = document.createElement("span");
1475
+ right.style.cssText = "color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums;font-size:10px";
1476
+ const latency = `${(c.latencyMs / 1000).toFixed(1)}s`;
1477
+ const cost = c.costUSD !== undefined ? ` · $${c.costUSD.toFixed(4)}` : "";
1478
+ const ago = ` · ${relativeTime(now - c.endedAt)}`;
1479
+ right.textContent = `${latency}${cost}${ago}`;
1480
+ row.appendChild(left);
1481
+ row.appendChild(right);
1482
+ sec.appendChild(row);
1483
+ }
1484
+ return sec;
1485
+ }
1486
+
1487
+ function renderSessionSection(s: SessionStats, now: number): HTMLDivElement {
1488
+ const sec = document.createElement("div");
1489
+ sec.appendChild(sectionLabel("Session", relativeTime(now - s.startedAt)));
1490
+ const row = document.createElement("div");
1491
+ row.style.cssText = "display:flex;justify-content:space-between;gap:12px;color:rgba(255,255,255,0.85);font-size:10px";
1492
+ const calls = document.createElement("span");
1493
+ calls.textContent = `${s.totalCalls} call${s.totalCalls === 1 ? "" : "s"}`;
1494
+ const cost = document.createElement("span");
1495
+ cost.textContent = `$${s.totalCostUSD.toFixed(4)}`;
1496
+ const errors = document.createElement("span");
1497
+ errors.textContent = s.errorCount > 0 ? `${s.errorCount} error${s.errorCount === 1 ? "" : "s"}` : "0 errors";
1498
+ if (s.errorCount > 0) errors.style.color = "#ef4444";
1499
+ row.appendChild(calls);
1500
+ row.appendChild(cost);
1501
+ row.appendChild(errors);
1502
+ sec.appendChild(row);
1503
+ return sec;
1504
+ }
1505
+
1506
+ function sectionLabel(title: string, hint: string): HTMLDivElement {
1507
+ const lbl = document.createElement("div");
1508
+ lbl.style.cssText = [
1509
+ "display:flex;justify-content:space-between;align-items:baseline",
1510
+ "margin-bottom:4px",
1511
+ "color:rgba(255,255,255,0.55)",
1512
+ "font-size:10px;text-transform:uppercase;letter-spacing:0.05em",
1513
+ ].join(";");
1514
+ const t = document.createElement("span");
1515
+ t.textContent = title;
1516
+ const h = document.createElement("span");
1517
+ h.textContent = hint;
1518
+ h.style.cssText = "font-size:9px;letter-spacing:0";
1519
+ lbl.appendChild(t);
1520
+ lbl.appendChild(h);
1521
+ return lbl;
1522
+ }
1523
+
1524
+ function relativeTime(ms: number): string {
1525
+ if (ms < 1500) return "just now";
1526
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`;
1527
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
1528
+ return `${Math.round(ms / 3_600_000)}h ago`;
1529
+ }