@ggui-ai/iframe-runtime 0.16.0 → 0.18.0

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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The dismiss INTENT the card forwards and never acts on (ggui#1109).
3
+ *
4
+ * An MCP-Apps card is an iframe: a key pressed while focus is inside its
5
+ * document never reaches the page around it. So a user who has just clicked
6
+ * the card — the exact moment they press Escape to close it — presses it into
7
+ * a document that has no reason to care and no way to pass it on. Before this,
8
+ * the runtime had NO keydown listener at all, and the surrounding host could
9
+ * add a perfect one and still never hear that gesture.
10
+ *
11
+ * What the card guarantees (protocol's docblock on `McpAppDismissMessage`, and
12
+ * this module is its implementation): it emits on the gesture, at most ONE
13
+ * intent per gesture — a held key auto-repeating is one gesture, so repeats are
14
+ * skipped — it does NOT act on it (no unmount, no visual dismissal, no state
15
+ * change), and it never learns whether the host honoured it. Ignoring is
16
+ * conformant: the host owns the surface.
17
+ */
18
+ import { type McpAppDismissReason } from '@ggui-ai/protocol/integrations/mcp-apps';
19
+ /** Post one intent to the embedding host. Best-effort, like every other parent post. */
20
+ export declare function postDismissIntent(reason: McpAppDismissReason): void;
21
+ /**
22
+ * Listen for the dismiss gesture inside the card document and forward it.
23
+ * Idempotent: a second call replaces the first listener rather than stacking a
24
+ * second one, so a re-boot in the same document cannot double-post.
25
+ */
26
+ export declare function installDismissIntentListener(doc?: Document): void;
27
+ /** Remove the listener (teardown; also the test seam). */
28
+ export declare function removeDismissIntentListener(doc?: Document): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The dismiss INTENT the card forwards and never acts on (ggui#1109).
3
+ *
4
+ * An MCP-Apps card is an iframe: a key pressed while focus is inside its
5
+ * document never reaches the page around it. So a user who has just clicked
6
+ * the card — the exact moment they press Escape to close it — presses it into
7
+ * a document that has no reason to care and no way to pass it on. Before this,
8
+ * the runtime had NO keydown listener at all, and the surrounding host could
9
+ * add a perfect one and still never hear that gesture.
10
+ *
11
+ * What the card guarantees (protocol's docblock on `McpAppDismissMessage`, and
12
+ * this module is its implementation): it emits on the gesture, at most ONE
13
+ * intent per gesture — a held key auto-repeating is one gesture, so repeats are
14
+ * skipped — it does NOT act on it (no unmount, no visual dismissal, no state
15
+ * change), and it never learns whether the host honoured it. Ignoring is
16
+ * conformant: the host owns the surface.
17
+ */
18
+ import { MCP_APP_DISMISS_TYPE, } from '@ggui-ai/protocol/integrations/mcp-apps';
19
+ /** The key that means "close this", as the platform names it. */
20
+ const ESCAPE_KEY = 'Escape';
21
+ let installed = null;
22
+ /** Post one intent to the embedding host. Best-effort, like every other parent post. */
23
+ export function postDismissIntent(reason) {
24
+ if (typeof window === 'undefined' || window.parent === null)
25
+ return;
26
+ const message = { type: MCP_APP_DISMISS_TYPE, reason };
27
+ try {
28
+ window.parent.postMessage(message, '*');
29
+ }
30
+ catch {
31
+ // Parent unreachable (detached window). Fire-and-forget — the same posture
32
+ // every other outbound notification takes, and dismissing is the host's
33
+ // decision anyway.
34
+ }
35
+ }
36
+ /**
37
+ * Listen for the dismiss gesture inside the card document and forward it.
38
+ * Idempotent: a second call replaces the first listener rather than stacking a
39
+ * second one, so a re-boot in the same document cannot double-post.
40
+ */
41
+ export function installDismissIntentListener(doc = document) {
42
+ removeDismissIntentListener(doc);
43
+ const listener = (ev) => {
44
+ if (ev.key !== ESCAPE_KEY)
45
+ return;
46
+ // A held key auto-repeating is ONE gesture (protocol's guarantee): a user
47
+ // leaning on Escape must not flood the host with intents.
48
+ if (ev.repeat)
49
+ return;
50
+ // The gesture is NOT consumed: no `preventDefault`, no `stopPropagation`.
51
+ // Anything else inside the card that listens for Escape — a menu, a
52
+ // combobox — keeps hearing it, and the host hears it too. The card
53
+ // forwards; it does not decide.
54
+ postDismissIntent('escape');
55
+ };
56
+ doc.addEventListener('keydown', listener);
57
+ installed = listener;
58
+ }
59
+ /** Remove the listener (teardown; also the test seam). */
60
+ export function removeDismissIntentListener(doc = document) {
61
+ if (installed === null)
62
+ return;
63
+ doc.removeEventListener('keydown', installed);
64
+ installed = null;
65
+ }
@@ -231,6 +231,14 @@ export function buildBridgePolling(opts) {
231
231
  // holds the card is presumed quiet; drop to sparse un-held
232
232
  // pulls every `idleIntervalMs` so a dormant card doesn't pin
233
233
  // the host's relay open forever.
234
+ // - A FAILED pull (the relay's own `tools/call` timeout, `-32001`,
235
+ // a transport error) is not a live session either (ggui#1029):
236
+ // it counts toward demotion exactly like an empty hold, and
237
+ // repeated failures back off geometrically from `intervalMs`
238
+ // up to `idleIntervalMs` — a relay whose call timeout is shorter
239
+ // than `holdSeconds` used to answer every held pull with an
240
+ // error, which never advanced the counter, so a dormant card
241
+ // re-fired a full hold every few seconds forever.
234
242
  // Any delivered event promotes straight back to subscription mode.
235
243
  let consecutiveEmpties = 0;
236
244
  const hot = () => consecutiveEmpties < demoteAfterEmpties;
@@ -246,6 +254,10 @@ export function buildBridgePolling(opts) {
246
254
  return unwrapCallToolResult(result);
247
255
  },
248
256
  parseSnapshot: createEventsSnapshotParser(cursor),
257
+ nextDelayOnFailureMs: (consecutiveFailures) => {
258
+ consecutiveEmpties += 1;
259
+ return Math.min(idleIntervalMs, intervalMs * 2 ** Math.max(0, consecutiveFailures - 1));
260
+ },
249
261
  nextDelayMs: (body) => {
250
262
  if (isEventsResponse(body)) {
251
263
  if (body.events.length > 0) {