@guuey/mcp-apps-host 0.18.5 → 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.
- package/dist/auto-resize.d.ts +50 -0
- package/dist/auto-resize.d.ts.map +1 -0
- package/dist/auto-resize.js +66 -0
- package/dist/react.d.ts +9 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +13 -5
- package/package.json +1 -1
- package/src/auto-resize.ts +96 -0
- package/src/react.tsx +22 -4
|
@@ -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`
|
package/dist/react.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
},
|