@guuey/mcp-apps-host 0.19.0 → 0.20.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,50 @@
1
+ /**
2
+ * The `autoResize` sizing POLICY (guuey#992) — the host's side of the
3
+ * `ui/notifications/size-changed` handshake, kept pure so the loop it
4
+ * prevents is pinned by `auto-resize.test.ts` without a DOM.
5
+ *
6
+ * Why a policy and not "apply the report": the view runtime
7
+ * (`@ggui-ai/iframe-runtime`) measures `documentElement` at `max-content`.
8
+ * A view whose body is viewport-bound (`min-height: 100vh` — routine in
9
+ * generated card CSS, and agent-generated HTML is not ours to control)
10
+ * measures the frame's OWN height plus a constant δ (margins, padding) and
11
+ * reports it back; a host that applies every report verbatim grows the
12
+ * frame by δ per tick without bound. The founder's first prod card sat at
13
+ * 86,816 px. The view cannot tell the two apart; the host can:
14
+ *
15
+ * 1. CEILING — a report is clamped to `ceiling` (the host viewport, or
16
+ * the embedder's `maxHeight`). A taller card scrolls inside its frame,
17
+ * as in every chat client.
18
+ * 2. ECHO DETECTOR — a SECOND consecutive growth by the same δ (±2 px),
19
+ * arriving within {@link ECHO_WINDOW_MS} of our own apply, is the view
20
+ * echoing the frame, not content: the frame is HELD. The runtime
21
+ * dedupes identical reports, so a held frame goes quiet.
22
+ *
23
+ * Everything else applies: the first report, every shrink (which also
24
+ * releases a hold), and a growth that is not an echo — real content growth
25
+ * (a section opened) still sizes the frame.
26
+ */
27
+ /** Two same-δ growths inside this window after our apply = the echo. */
28
+ export declare const ECHO_WINDOW_MS = 1000;
29
+ /** δ tolerance between the two growths — sub-pixel rounding, not content. */
30
+ export declare const ECHO_DELTA_TOLERANCE_PX = 2;
31
+ export interface AutoResizeState {
32
+ /** The height the host last applied to the frame, if any. */
33
+ readonly applied?: number;
34
+ /** When it was applied (ms clock the caller supplies). */
35
+ readonly appliedAt?: number;
36
+ /** The growth (report − applied) the last apply was, if it was a growth. */
37
+ readonly lastDelta?: number;
38
+ /** When the last report arrived — a held frame stays held while growth reports keep coming. */
39
+ readonly lastReportAt?: number;
40
+ /** True while the frame is held against an echo. */
41
+ readonly held: boolean;
42
+ }
43
+ export interface AutoResizeDecision {
44
+ /** The height to apply now, or `undefined` to leave the frame as it is. */
45
+ readonly apply: number | undefined;
46
+ readonly state: AutoResizeState;
47
+ }
48
+ export declare function initialAutoResizeState(): AutoResizeState;
49
+ export declare function decideAutoResize(state: AutoResizeState, reportedHeight: number, nowMs: number, ceiling?: number): AutoResizeDecision;
50
+ //# sourceMappingURL=auto-resize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-resize.d.ts","sourceRoot":"","sources":["../src/auto-resize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,wEAAwE;AACxE,eAAO,MAAM,cAAc,OAAQ,CAAC;AACpC,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,MAAM,WAAW,eAAe;IAC9B,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,+FAA+F;IAC/F,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CACjC;AAED,wBAAgB,sBAAsB,IAAI,eAAe,CAExD;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,eAAe,EACtB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,GACf,kBAAkB,CAmCpB"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The `autoResize` sizing POLICY (guuey#992) — the host's side of the
3
+ * `ui/notifications/size-changed` handshake, kept pure so the loop it
4
+ * prevents is pinned by `auto-resize.test.ts` without a DOM.
5
+ *
6
+ * Why a policy and not "apply the report": the view runtime
7
+ * (`@ggui-ai/iframe-runtime`) measures `documentElement` at `max-content`.
8
+ * A view whose body is viewport-bound (`min-height: 100vh` — routine in
9
+ * generated card CSS, and agent-generated HTML is not ours to control)
10
+ * measures the frame's OWN height plus a constant δ (margins, padding) and
11
+ * reports it back; a host that applies every report verbatim grows the
12
+ * frame by δ per tick without bound. The founder's first prod card sat at
13
+ * 86,816 px. The view cannot tell the two apart; the host can:
14
+ *
15
+ * 1. CEILING — a report is clamped to `ceiling` (the host viewport, or
16
+ * the embedder's `maxHeight`). A taller card scrolls inside its frame,
17
+ * as in every chat client.
18
+ * 2. ECHO DETECTOR — a SECOND consecutive growth by the same δ (±2 px),
19
+ * arriving within {@link ECHO_WINDOW_MS} of our own apply, is the view
20
+ * echoing the frame, not content: the frame is HELD. The runtime
21
+ * dedupes identical reports, so a held frame goes quiet.
22
+ *
23
+ * Everything else applies: the first report, every shrink (which also
24
+ * releases a hold), and a growth that is not an echo — real content growth
25
+ * (a section opened) still sizes the frame.
26
+ */
27
+ /** Two same-δ growths inside this window after our apply = the echo. */
28
+ export const ECHO_WINDOW_MS = 1_000;
29
+ /** δ tolerance between the two growths — sub-pixel rounding, not content. */
30
+ export const ECHO_DELTA_TOLERANCE_PX = 2;
31
+ export function initialAutoResizeState() {
32
+ return { held: false };
33
+ }
34
+ export function decideAutoResize(state, reportedHeight, nowMs, ceiling) {
35
+ const target = ceiling !== undefined ? Math.min(reportedHeight, ceiling) : reportedHeight;
36
+ const { applied } = state;
37
+ // First report, or nothing to change.
38
+ if (applied === undefined) {
39
+ return { apply: target, state: { applied: target, appliedAt: nowMs, lastReportAt: nowMs, held: false } };
40
+ }
41
+ if (target === applied)
42
+ return { apply: undefined, state: { ...state, lastReportAt: nowMs } };
43
+ // A shrink always applies and releases any hold.
44
+ if (target < applied) {
45
+ return { apply: target, state: { applied: target, appliedAt: nowMs, lastReportAt: nowMs, held: false } };
46
+ }
47
+ // A growth while HELD: growth reports still arriving in quick succession
48
+ // are the same echo (or a view growing on its own clock regardless of the
49
+ // frame — equally not a reason to move); a growth after a quiet window is
50
+ // content and falls through to a fresh judgement.
51
+ if (state.held && state.lastReportAt !== undefined && nowMs - state.lastReportAt <= ECHO_WINDOW_MS) {
52
+ return { apply: undefined, state: { ...state, lastReportAt: nowMs } };
53
+ }
54
+ // A growth: the echo test — the same δ as the growth we just applied,
55
+ // inside the window of that apply, is the view measuring the taller frame.
56
+ const delta = target - applied;
57
+ const withinWindow = state.appliedAt !== undefined && nowMs - state.appliedAt <= ECHO_WINDOW_MS;
58
+ const sameDelta = state.lastDelta !== undefined && Math.abs(delta - state.lastDelta) <= ECHO_DELTA_TOLERANCE_PX;
59
+ if (withinWindow && sameDelta) {
60
+ return { apply: undefined, state: { ...state, lastReportAt: nowMs, held: true } };
61
+ }
62
+ return {
63
+ apply: target,
64
+ state: { applied: target, appliedAt: nowMs, lastDelta: delta, lastReportAt: nowMs, held: false },
65
+ };
66
+ }
package/dist/react.d.ts CHANGED
@@ -76,6 +76,15 @@ export interface GuueyViewProps extends Pick<AttachViewHostConfig, "hostCapabili
76
76
  * fires either way.
77
77
  */
78
78
  autoResize?: boolean;
79
+ /**
80
+ * The tallest frame `autoResize` may apply, in px (guuey#992). Default:
81
+ * the host window's `innerHeight` at the time of the report — a card
82
+ * taller than the viewport scrolls inside its frame. Reports are also
83
+ * judged by the echo detector in `auto-resize.ts`: a view whose body is
84
+ * viewport-bound measures the frame's own height back to the host, and a
85
+ * host that applied every report verbatim grew a frame to 86,816 px.
86
+ */
87
+ maxHeight?: number;
79
88
  /**
80
89
  * Sandbox flags appended to the safe default (`allow-scripts`). Every
81
90
  * entry widens what agent-generated HTML may do — `allow-same-origin`
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAAwC,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACjG,OAAO,EAAoC,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE7F,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACzG,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,0BAA0B,EAC1B,6BAA6B,EAC7B,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC/F,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAKtF,MAAM,WAAW,cACf,SAAQ,IAAI,CACV,oBAAoB,EAClB,kBAAkB,GAClB,UAAU,GACV,aAAa,GACb,YAAY,GACZ,sBAAsB,GACtB,eAAe,GACf,YAAY,GACZ,gBAAgB,GAChB,eAAe,GACf,sBAAsB,GACtB,YAAY,GACZ,gBAAgB,CACnB;IACD,iFAAiF;IACjF,KAAK,EAAE,iBAAiB,CAAC;IACzB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;OAMG;IACH,0BAA0B,CAAC,EAAE,MAAM,EAAE,CAAC;IACtC,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,gBAAgB,KAAK,SAAS,CAAC;CAClF;AA8CD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,CA0K1D"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAAwC,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACjG,OAAO,EAAoC,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG7F,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACzG,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,0BAA0B,EAC1B,6BAA6B,EAC7B,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC/F,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAKtF,MAAM,WAAW,cACf,SAAQ,IAAI,CACV,oBAAoB,EAClB,kBAAkB,GAClB,UAAU,GACV,aAAa,GACb,YAAY,GACZ,sBAAsB,GACtB,eAAe,GACf,YAAY,GACZ,gBAAgB,GAChB,eAAe,GACf,sBAAsB,GACtB,YAAY,GACZ,gBAAgB,CACnB;IACD,iFAAiF;IACjF,KAAK,EAAE,iBAAiB,CAAC;IACzB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,0BAA0B,CAAC,EAAE,MAAM,EAAE,CAAC;IACtC,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,CAAC,EAAE,gBAAgB,KAAK,SAAS,CAAC;CAClF;AA8CD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,CAkL1D"}
package/dist/react.js CHANGED
@@ -36,6 +36,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
36
36
  import { useEffect, useMemo, useRef, useState } from "react";
37
37
  import { attachViewHost, viewDocumentHtml } from "./view-host.js";
38
38
  import { attachSandboxPageDelivery } from "./sandbox-page.js";
39
+ import { decideAutoResize, initialAutoResizeState } from "./auto-resize.js";
39
40
  export { attachViewHost, viewDocumentHtml } from "./view-host.js";
40
41
  export { attachSandboxPageDelivery, isSandboxProxyReady, SANDBOX_PROXY_READY_METHOD, SANDBOX_RESOURCE_READY_METHOD, } from "./sandbox-page.js";
41
42
  /** Accessible name for a mounted view when the caller has nothing better. */
@@ -75,13 +76,15 @@ function defaultStatus(phase, channel, diagnosis) {
75
76
  * docblock for the sandbox and state contracts.
76
77
  */
77
78
  export function GuueyView(props) {
78
- const { mount, sandboxPageUrl, autoResize, dangerouslyAddSandboxFlags, allow, title, className, style, onPhaseChange, renderStatus, ...hostConfig } = props;
79
+ const { mount, sandboxPageUrl, autoResize, maxHeight, dangerouslyAddSandboxFlags, allow, title, className, style, onPhaseChange, renderStatus, ...hostConfig } = props;
79
80
  const frameRef = useRef(null);
80
81
  const [phase, setPhase] = useState("negotiating");
81
82
  // The CSP tripwire's verdict for THIS document, if any (guuey#235).
82
83
  const [diagnosis, setDiagnosis] = useState(undefined);
83
- // The view's own size report, applied only under `autoResize`.
84
+ // The view's own size report, applied only under `autoResize` — and only
85
+ // as the sizing policy allows (ceiling + echo detector, guuey#992).
84
86
  const [reportedHeight, setReportedHeight] = useState(undefined);
87
+ const sizing = useRef(initialAutoResizeState());
85
88
  const html = viewDocumentHtml(mount.resource);
86
89
  // Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's
87
90
  // ResourceMount precedent, generalized): the whole point of the page is
@@ -109,8 +112,8 @@ export function GuueyView(props) {
109
112
  // The attachment is keyed to the mounted DOCUMENT, not to every render's
110
113
  // fresh callback identities — host config rides a ref so the effect's
111
114
  // dependency list is honestly just the document identity.
112
- const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize });
113
- latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize };
115
+ const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight });
116
+ latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight };
114
117
  useEffect(() => {
115
118
  // Keyed to the same identity the frame is (the resource uri): a new
116
119
  // document boots fresh, and the previous negotiation's phase must not
@@ -118,6 +121,7 @@ export function GuueyView(props) {
118
121
  setPhase("negotiating");
119
122
  setDiagnosis(undefined);
120
123
  setReportedHeight(undefined);
124
+ sizing.current = initialAutoResizeState();
121
125
  const frame = frameRef.current;
122
126
  if (frame === null || html === undefined)
123
127
  return;
@@ -144,7 +148,11 @@ export function GuueyView(props) {
144
148
  },
145
149
  onSizeChanged: (size) => {
146
150
  if (latest.current.autoResize === true && size.height !== undefined) {
147
- setReportedHeight(size.height);
151
+ const ceiling = latest.current.maxHeight ?? (typeof window !== "undefined" ? window.innerHeight : undefined);
152
+ const decision = decideAutoResize(sizing.current, size.height, Date.now(), ceiling);
153
+ sizing.current = decision.state;
154
+ if (decision.apply !== undefined)
155
+ setReportedHeight(decision.apply);
148
156
  }
149
157
  latest.current.hostConfig.onSizeChanged?.(size);
150
158
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/mcp-apps-host",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "The MCP Apps (SEP-1865) Host role for guuey's chat surfaces — view-mount narrowing across UI channels, ui:// locator rehydration by resources/read, and the sandbox-trust channel contract. Vendor-neutral: any spec-following MCP App mounts through it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The `autoResize` sizing POLICY (guuey#992) — the host's side of the
3
+ * `ui/notifications/size-changed` handshake, kept pure so the loop it
4
+ * prevents is pinned by `auto-resize.test.ts` without a DOM.
5
+ *
6
+ * Why a policy and not "apply the report": the view runtime
7
+ * (`@ggui-ai/iframe-runtime`) measures `documentElement` at `max-content`.
8
+ * A view whose body is viewport-bound (`min-height: 100vh` — routine in
9
+ * generated card CSS, and agent-generated HTML is not ours to control)
10
+ * measures the frame's OWN height plus a constant δ (margins, padding) and
11
+ * reports it back; a host that applies every report verbatim grows the
12
+ * frame by δ per tick without bound. The founder's first prod card sat at
13
+ * 86,816 px. The view cannot tell the two apart; the host can:
14
+ *
15
+ * 1. CEILING — a report is clamped to `ceiling` (the host viewport, or
16
+ * the embedder's `maxHeight`). A taller card scrolls inside its frame,
17
+ * as in every chat client.
18
+ * 2. ECHO DETECTOR — a SECOND consecutive growth by the same δ (±2 px),
19
+ * arriving within {@link ECHO_WINDOW_MS} of our own apply, is the view
20
+ * echoing the frame, not content: the frame is HELD. The runtime
21
+ * dedupes identical reports, so a held frame goes quiet.
22
+ *
23
+ * Everything else applies: the first report, every shrink (which also
24
+ * releases a hold), and a growth that is not an echo — real content growth
25
+ * (a section opened) still sizes the frame.
26
+ */
27
+
28
+ /** Two same-δ growths inside this window after our apply = the echo. */
29
+ export const ECHO_WINDOW_MS = 1_000;
30
+ /** δ tolerance between the two growths — sub-pixel rounding, not content. */
31
+ export const ECHO_DELTA_TOLERANCE_PX = 2;
32
+
33
+ export interface AutoResizeState {
34
+ /** The height the host last applied to the frame, if any. */
35
+ readonly applied?: number;
36
+ /** When it was applied (ms clock the caller supplies). */
37
+ readonly appliedAt?: number;
38
+ /** The growth (report − applied) the last apply was, if it was a growth. */
39
+ readonly lastDelta?: number;
40
+ /** When the last report arrived — a held frame stays held while growth reports keep coming. */
41
+ readonly lastReportAt?: number;
42
+ /** True while the frame is held against an echo. */
43
+ readonly held: boolean;
44
+ }
45
+
46
+ export interface AutoResizeDecision {
47
+ /** The height to apply now, or `undefined` to leave the frame as it is. */
48
+ readonly apply: number | undefined;
49
+ readonly state: AutoResizeState;
50
+ }
51
+
52
+ export function initialAutoResizeState(): AutoResizeState {
53
+ return { held: false };
54
+ }
55
+
56
+ export function decideAutoResize(
57
+ state: AutoResizeState,
58
+ reportedHeight: number,
59
+ nowMs: number,
60
+ ceiling?: number,
61
+ ): AutoResizeDecision {
62
+ const target = ceiling !== undefined ? Math.min(reportedHeight, ceiling) : reportedHeight;
63
+ const { applied } = state;
64
+
65
+ // First report, or nothing to change.
66
+ if (applied === undefined) {
67
+ return { apply: target, state: { applied: target, appliedAt: nowMs, lastReportAt: nowMs, held: false } };
68
+ }
69
+ if (target === applied) return { apply: undefined, state: { ...state, lastReportAt: nowMs } };
70
+
71
+ // A shrink always applies and releases any hold.
72
+ if (target < applied) {
73
+ return { apply: target, state: { applied: target, appliedAt: nowMs, lastReportAt: nowMs, held: false } };
74
+ }
75
+
76
+ // A growth while HELD: growth reports still arriving in quick succession
77
+ // are the same echo (or a view growing on its own clock regardless of the
78
+ // frame — equally not a reason to move); a growth after a quiet window is
79
+ // content and falls through to a fresh judgement.
80
+ if (state.held && state.lastReportAt !== undefined && nowMs - state.lastReportAt <= ECHO_WINDOW_MS) {
81
+ return { apply: undefined, state: { ...state, lastReportAt: nowMs } };
82
+ }
83
+
84
+ // A growth: the echo test — the same δ as the growth we just applied,
85
+ // inside the window of that apply, is the view measuring the taller frame.
86
+ const delta = target - applied;
87
+ const withinWindow = state.appliedAt !== undefined && nowMs - state.appliedAt <= ECHO_WINDOW_MS;
88
+ const sameDelta = state.lastDelta !== undefined && Math.abs(delta - state.lastDelta) <= ECHO_DELTA_TOLERANCE_PX;
89
+ if (withinWindow && sameDelta) {
90
+ return { apply: undefined, state: { ...state, lastReportAt: nowMs, held: true } };
91
+ }
92
+ return {
93
+ apply: target,
94
+ state: { applied: target, appliedAt: nowMs, lastDelta: delta, lastReportAt: nowMs, held: false },
95
+ };
96
+ }
package/src/react.tsx CHANGED
@@ -35,6 +35,7 @@
35
35
  import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
36
36
  import { attachViewHost, viewDocumentHtml, type AttachViewHostConfig } from "./view-host.js";
37
37
  import { attachSandboxPageDelivery } from "./sandbox-page.js";
38
+ import { decideAutoResize, initialAutoResizeState, type AutoResizeState } from "./auto-resize.js";
38
39
  import type { ViewCspDiagnosis, ViewHostPhase } from "./view-host-protocol.js";
39
40
  import type { ResolvedViewMount } from "./card-mount.js";
40
41
 
@@ -103,6 +104,15 @@ export interface GuueyViewProps
103
104
  * fires either way.
104
105
  */
105
106
  autoResize?: boolean;
107
+ /**
108
+ * The tallest frame `autoResize` may apply, in px (guuey#992). Default:
109
+ * the host window's `innerHeight` at the time of the report — a card
110
+ * taller than the viewport scrolls inside its frame. Reports are also
111
+ * judged by the echo detector in `auto-resize.ts`: a view whose body is
112
+ * viewport-bound measures the frame's own height back to the host, and a
113
+ * host that applied every report verbatim grew a frame to 86,816 px.
114
+ */
115
+ maxHeight?: number;
106
116
  /**
107
117
  * Sandbox flags appended to the safe default (`allow-scripts`). Every
108
118
  * entry widens what agent-generated HTML may do — `allow-same-origin`
@@ -185,6 +195,7 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
185
195
  mount,
186
196
  sandboxPageUrl,
187
197
  autoResize,
198
+ maxHeight,
188
199
  dangerouslyAddSandboxFlags,
189
200
  allow,
190
201
  title,
@@ -198,8 +209,10 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
198
209
  const [phase, setPhase] = useState<ViewHostPhase>("negotiating");
199
210
  // The CSP tripwire's verdict for THIS document, if any (guuey#235).
200
211
  const [diagnosis, setDiagnosis] = useState<ViewCspDiagnosis | undefined>(undefined);
201
- // The view's own size report, applied only under `autoResize`.
212
+ // The view's own size report, applied only under `autoResize` — and only
213
+ // as the sizing policy allows (ceiling + echo detector, guuey#992).
202
214
  const [reportedHeight, setReportedHeight] = useState<number | undefined>(undefined);
215
+ const sizing = useRef<AutoResizeState>(initialAutoResizeState());
203
216
  const html = viewDocumentHtml(mount.resource);
204
217
 
205
218
  // Vet the sandbox page once per URL. Same-origin is REFUSED (the widget's
@@ -225,8 +238,8 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
225
238
  // The attachment is keyed to the mounted DOCUMENT, not to every render's
226
239
  // fresh callback identities — host config rides a ref so the effect's
227
240
  // dependency list is honestly just the document identity.
228
- const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize });
229
- latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize };
241
+ const latest = useRef({ hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight });
242
+ latest.current = { hostConfig, onPhaseChange, dangerouslyAddSandboxFlags, autoResize, maxHeight };
230
243
 
231
244
  useEffect(() => {
232
245
  // Keyed to the same identity the frame is (the resource uri): a new
@@ -235,6 +248,7 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
235
248
  setPhase("negotiating");
236
249
  setDiagnosis(undefined);
237
250
  setReportedHeight(undefined);
251
+ sizing.current = initialAutoResizeState();
238
252
  const frame = frameRef.current;
239
253
  if (frame === null || html === undefined) return;
240
254
  if (sandboxPageUrl !== undefined && page === undefined) return; // refused config — nothing mounts
@@ -259,7 +273,11 @@ export function GuueyView(props: GuueyViewProps): ReactNode {
259
273
  },
260
274
  onSizeChanged: (size) => {
261
275
  if (latest.current.autoResize === true && size.height !== undefined) {
262
- setReportedHeight(size.height);
276
+ const ceiling =
277
+ latest.current.maxHeight ?? (typeof window !== "undefined" ? window.innerHeight : undefined);
278
+ const decision = decideAutoResize(sizing.current, size.height, Date.now(), ceiling);
279
+ sizing.current = decision.state;
280
+ if (decision.apply !== undefined) setReportedHeight(decision.apply);
263
281
  }
264
282
  latest.current.hostConfig.onSizeChanged?.(size);
265
283
  },