@ggui-ai/iframe-runtime 0.6.2 → 0.7.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/channel-transport.d.ts +76 -19
- package/dist/channel-transport.js +137 -29
- package/dist/channels/index.d.ts +0 -1
- package/dist/channels/index.js +0 -1
- package/dist/host-capabilities.d.ts +52 -0
- package/dist/host-capabilities.js +54 -0
- package/dist/iframe-runtime.js +81 -81
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/mcp-app-iframe-host.js +8 -1
- package/dist/observability.d.ts +76 -52
- package/dist/relay-incapability.d.ts +64 -0
- package/dist/relay-incapability.js +69 -0
- package/dist/runtime.d.ts +123 -10
- package/dist/runtime.js +706 -52
- package/package.json +7 -7
- package/dist/channels/system.d.ts +0 -26
- package/dist/channels/system.js +0 -44
package/dist/runtime.js
CHANGED
|
@@ -5,8 +5,9 @@ import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
|
5
5
|
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
|
|
6
6
|
import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
7
7
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
8
|
+
import { RelayIncapableError } from './relay-incapability.js';
|
|
8
9
|
import { ChannelRegistry } from '@ggui-ai/live-channel';
|
|
9
|
-
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler,
|
|
10
|
+
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
|
|
10
11
|
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
11
12
|
import { buildEventsPolling } from './events-polling.js';
|
|
12
13
|
import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
|
|
@@ -16,6 +17,7 @@ import { mountRender, } from './render-item.js';
|
|
|
16
17
|
import { fromBootstrapFailure, } from './protocol-error.js';
|
|
17
18
|
import { postObservabilityToParent, } from './observability.js';
|
|
18
19
|
import { mountUiFeedbackChrome } from './ui-feedback-chrome.js';
|
|
20
|
+
import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
|
|
19
21
|
import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
|
|
20
22
|
import { contextSlotLastValues, createContextStateHost, installContextRegistry, reemitLastContextValues, } from './context-observer.js';
|
|
21
23
|
const RENDERER_VERSION = typeof __GGUI_RUNTIME_VERSION__ === 'string' ? __GGUI_RUNTIME_VERSION__ : 'dev';
|
|
@@ -214,6 +216,14 @@ export async function bootSequence(opts) {
|
|
|
214
216
|
onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
|
|
215
217
|
};
|
|
216
218
|
const refs = ensureStatusDom(doc);
|
|
219
|
+
// Live regions up BEFORE anything can announce through them
|
|
220
|
+
// (ggui#447). A region created in the same tick as its first message
|
|
221
|
+
// is a region no screen reader was watching yet, and that first
|
|
222
|
+
// message — the one telling the user their very first gesture was
|
|
223
|
+
// received — is the one that gets lost. Mounted here, alongside the
|
|
224
|
+
// render root, because every toast is at minimum a user gesture away
|
|
225
|
+
// from this point.
|
|
226
|
+
ensureToastAnnouncer(doc);
|
|
217
227
|
// The mounted render is established after the first ack lands —
|
|
218
228
|
// populated by `applyAck` below + read back by every channel handler
|
|
219
229
|
// that needs `propsSpec` / `streamSpec`. Tracked as a closure-scoped
|
|
@@ -253,6 +263,11 @@ export async function bootSequence(opts) {
|
|
|
253
263
|
emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
|
|
254
264
|
return { ok: false, mountedRender };
|
|
255
265
|
}
|
|
266
|
+
// Record what this host said it can do (ggui#440). Earliest valid
|
|
267
|
+
// read: `getHostCapabilities()` is undefined until `connect()`
|
|
268
|
+
// resolves. Used to EXPLAIN failures (gesture relay, doorbell), never
|
|
269
|
+
// to pre-empt an attempt — see `./host-capabilities.ts`.
|
|
270
|
+
setHostCapabilities(app.getHostCapabilities());
|
|
256
271
|
// Connected — expose the App on the module-level slot so outbound
|
|
257
272
|
// `tools/call` (dispatchSubmitAction, channel-transport router)
|
|
258
273
|
// routes through `app.callServerTool` instead of the legacy raw
|
|
@@ -683,15 +698,13 @@ function createPlaceholderRegistry(params) {
|
|
|
683
698
|
* Frame dispatch lives inside `@ggui-ai/live-channel`'s
|
|
684
699
|
* `ChannelRegistry`. Every WS frame type (`render`, `data`,
|
|
685
700
|
* `props_update`, `drain_ack`, `channel_payload`,
|
|
686
|
-
* `channel_error
|
|
687
|
-
*
|
|
688
|
-
* frames directly to them. Observability emission (`auth-required`)
|
|
689
|
-
* is inside the system handler.
|
|
701
|
+
* `channel_error`) has a registered handler in `channels/*.ts`; the
|
|
702
|
+
* registry's bound transport routes inbound frames directly to them.
|
|
690
703
|
*
|
|
691
704
|
* The pre-B3b helpers (`handleServerMessage`, `handleRendererMessage`,
|
|
692
|
-
* `handleObservableMessage`, `
|
|
693
|
-
*
|
|
694
|
-
*
|
|
705
|
+
* `handleObservableMessage`, `BufferedManagerShim`) lived here and
|
|
706
|
+
* have been retired — see commit message + plan B3b for the full
|
|
707
|
+
* retirement notes.
|
|
695
708
|
*/
|
|
696
709
|
/**
|
|
697
710
|
* Type guard for the `UpgradeRequiredError` class without importing
|
|
@@ -1253,6 +1266,32 @@ function classifySubmitActionResponse(resp) {
|
|
|
1253
1266
|
: r;
|
|
1254
1267
|
return inner.ok === true ? 'success' : 'fallback';
|
|
1255
1268
|
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Did the relay itself fail — as opposed to relaying successfully to a
|
|
1271
|
+
* well-formed `{ok:false}` result (ggui#440)? `classifySubmitActionResponse`
|
|
1272
|
+
* collapses BOTH into `'fallback'`, which is the right call for the
|
|
1273
|
+
* generic "enqueue failed" toast (either way the gesture isn't on the
|
|
1274
|
+
* pipe) but the WRONG call for the relay-incapability latch: a result
|
|
1275
|
+
* envelope arriving at all — `{ok:false, code:'PIPE_NOT_FOUND'}` is the
|
|
1276
|
+
* common case (an otherwise-healthy relay, expired pipe) — is proof the
|
|
1277
|
+
* host DID relay the call to the server and back. Latching "cannot
|
|
1278
|
+
* relay" on that evidence would be false: a host that plainly can relay
|
|
1279
|
+
* would get permanently mislabeled from one stale-pipe response.
|
|
1280
|
+
*
|
|
1281
|
+
* `resp === null` covers the transport-throw / no-App-bound paths (the
|
|
1282
|
+
* outer `catch` above resets `resp` to `null`); `resp.error !== undefined
|
|
1283
|
+
* && resp.error !== null` covers the JSON-RPC error envelope. Neither
|
|
1284
|
+
* carries a result envelope, so neither is evidence the relay worked.
|
|
1285
|
+
*/
|
|
1286
|
+
function isRelayShapedFailure(resp) {
|
|
1287
|
+
// Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
|
|
1288
|
+
// own check: an `{error:null, result:{...}}` envelope classifies as
|
|
1289
|
+
// success there, so it must also read as NOT relay-shaped-failure
|
|
1290
|
+
// here — otherwise the two predicates disagree on that shape and the
|
|
1291
|
+
// response-arrival clear guard above would refuse to release a
|
|
1292
|
+
// standing latch on an unambiguous success.
|
|
1293
|
+
return resp === null || (resp.error !== undefined && resp.error !== null);
|
|
1294
|
+
}
|
|
1256
1295
|
/**
|
|
1257
1296
|
* Extract `consumerPresent` from a successful submit_action response.
|
|
1258
1297
|
* Returns the boolean if present + well-typed; `undefined` otherwise
|
|
@@ -1275,7 +1314,147 @@ function extractConsumerPresent(resp) {
|
|
|
1275
1314
|
const flag = inner.consumerPresent;
|
|
1276
1315
|
return typeof flag === 'boolean' ? flag : undefined;
|
|
1277
1316
|
}
|
|
1278
|
-
|
|
1317
|
+
/** Container id for the announcer holding both live regions. */
|
|
1318
|
+
const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
|
|
1319
|
+
/**
|
|
1320
|
+
* Ensure the pair of live regions the toast surface announces through.
|
|
1321
|
+
*
|
|
1322
|
+
* MUST be called before the first message can land — a live region
|
|
1323
|
+
* created in the same tick as its first content is a region the screen
|
|
1324
|
+
* reader was never watching, and that first announcement is lost. The
|
|
1325
|
+
* boot path calls this at the same point it mounts the render root, so
|
|
1326
|
+
* every toast (at minimum a user gesture away) lands in a region that
|
|
1327
|
+
* has been registered for many frames. The toast + cue paths call it
|
|
1328
|
+
* again defensively; it is idempotent per document.
|
|
1329
|
+
*
|
|
1330
|
+
* Visually hidden rather than `display:none`: a region that is not
|
|
1331
|
+
* rendered is not in the accessibility tree, and content added to it is
|
|
1332
|
+
* never announced. The clip rectangle keeps it out of the visual layout
|
|
1333
|
+
* while leaving it live.
|
|
1334
|
+
*
|
|
1335
|
+
* Returns `null` when the document has no body yet — nothing to attach
|
|
1336
|
+
* to, and no announcement is possible.
|
|
1337
|
+
*
|
|
1338
|
+
* @internal — exported for unit tests.
|
|
1339
|
+
*/
|
|
1340
|
+
export function ensureToastAnnouncer(doc) {
|
|
1341
|
+
if (doc.body === null)
|
|
1342
|
+
return null;
|
|
1343
|
+
const existing = doc.getElementById(TOAST_ANNOUNCER_ID);
|
|
1344
|
+
if (existing !== null) {
|
|
1345
|
+
const polite = existing.querySelector('[data-ggui-toast-announce="polite"]');
|
|
1346
|
+
const assertive = existing.querySelector('[data-ggui-toast-announce="assertive"]');
|
|
1347
|
+
if (polite !== null && assertive !== null)
|
|
1348
|
+
return { polite, assertive };
|
|
1349
|
+
// Half a container is worse than none — it looks mounted to the
|
|
1350
|
+
// idempotence check while announcing nothing. Rebuild whole.
|
|
1351
|
+
existing.remove();
|
|
1352
|
+
}
|
|
1353
|
+
const host = doc.createElement('div');
|
|
1354
|
+
host.id = TOAST_ANNOUNCER_ID;
|
|
1355
|
+
host.style.cssText = [
|
|
1356
|
+
'position:absolute',
|
|
1357
|
+
'width:1px',
|
|
1358
|
+
'height:1px',
|
|
1359
|
+
'margin:-1px',
|
|
1360
|
+
'padding:0',
|
|
1361
|
+
'border:0',
|
|
1362
|
+
'overflow:hidden',
|
|
1363
|
+
'clip:rect(0 0 0 0)',
|
|
1364
|
+
'clip-path:inset(50%)',
|
|
1365
|
+
'white-space:nowrap',
|
|
1366
|
+
].join(';');
|
|
1367
|
+
const makeRegion = (politeness) => {
|
|
1368
|
+
const region = doc.createElement('div');
|
|
1369
|
+
region.setAttribute('data-ggui-toast-announce', politeness);
|
|
1370
|
+
// Role and `aria-live` together: the role is the semantic, the
|
|
1371
|
+
// explicit `aria-live` is the belt for assistive technology that
|
|
1372
|
+
// does not map `role="status"` to a politeness on its own.
|
|
1373
|
+
region.setAttribute('role', politeness === 'polite' ? 'status' : 'alert');
|
|
1374
|
+
region.setAttribute('aria-live', politeness);
|
|
1375
|
+
// Toast text is one sentence that only makes sense whole — read the
|
|
1376
|
+
// region, not the diff.
|
|
1377
|
+
region.setAttribute('aria-atomic', 'true');
|
|
1378
|
+
host.appendChild(region);
|
|
1379
|
+
return region;
|
|
1380
|
+
};
|
|
1381
|
+
const polite = makeRegion('polite');
|
|
1382
|
+
const assertive = makeRegion('assertive');
|
|
1383
|
+
doc.body.appendChild(host);
|
|
1384
|
+
return { polite, assertive };
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Speak a toast message. `error` + `action_required` interrupt (a
|
|
1388
|
+
* gesture failed, or needs the user to act); everything else queues.
|
|
1389
|
+
*
|
|
1390
|
+
* Exactly one region carries text at a time — the other is cleared, so
|
|
1391
|
+
* a user who navigates to the announcer afterwards finds the current
|
|
1392
|
+
* message and not a transcript. Clearing is silent: assistive
|
|
1393
|
+
* technology announces content arriving in a live region, not content
|
|
1394
|
+
* leaving one.
|
|
1395
|
+
*/
|
|
1396
|
+
function announceToast(text, kind) {
|
|
1397
|
+
if (typeof document === 'undefined')
|
|
1398
|
+
return;
|
|
1399
|
+
const regions = ensureToastAnnouncer(document);
|
|
1400
|
+
if (regions === null)
|
|
1401
|
+
return;
|
|
1402
|
+
// Whatever speaks last owns the region. A retraction scheduled for an
|
|
1403
|
+
// earlier relay cue would otherwise come due in the middle of THIS
|
|
1404
|
+
// message and take it down instead — and it could not be filtered out
|
|
1405
|
+
// by comparing text, because a cue and the fallback cue toast for the
|
|
1406
|
+
// same intent are character-identical. (Declared below; this only
|
|
1407
|
+
// ever runs long after module init.)
|
|
1408
|
+
cancelRelayCueRetraction();
|
|
1409
|
+
const interrupts = kind === 'error' || kind === 'action_required';
|
|
1410
|
+
const target = interrupts ? regions.assertive : regions.polite;
|
|
1411
|
+
const idle = interrupts ? regions.polite : regions.assertive;
|
|
1412
|
+
idle.textContent = '';
|
|
1413
|
+
target.textContent = text;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Empty both live regions. Paired with every route that takes the
|
|
1417
|
+
* visible toast away, so the spoken surface and the visual one carry
|
|
1418
|
+
* the same message at the same time — including "nothing".
|
|
1419
|
+
*/
|
|
1420
|
+
function clearToastAnnouncement() {
|
|
1421
|
+
if (typeof document === 'undefined')
|
|
1422
|
+
return;
|
|
1423
|
+
const host = document.getElementById(TOAST_ANNOUNCER_ID);
|
|
1424
|
+
if (host === null)
|
|
1425
|
+
return;
|
|
1426
|
+
for (const region of Array.from(host.children)) {
|
|
1427
|
+
region.textContent = '';
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Take the visible toast away, on every route that does so (auto-
|
|
1432
|
+
* dismiss timer, user dismissal, `drain_ack` resolution).
|
|
1433
|
+
*
|
|
1434
|
+
* A hidden toast must be inert to pointer, keyboard AND assistive
|
|
1435
|
+
* technology alike: the element keeps its fixed position over the top
|
|
1436
|
+
* of the render for the rest of the session, so anything left behind —
|
|
1437
|
+
* a live click target, a tab stop, an announced sentence — outlives the
|
|
1438
|
+
* message it belonged to.
|
|
1439
|
+
*/
|
|
1440
|
+
function hideToast(el) {
|
|
1441
|
+
el.style.opacity = '0';
|
|
1442
|
+
el.style.transform = 'translateX(-50%) translateY(8px)';
|
|
1443
|
+
el.style.pointerEvents = 'none';
|
|
1444
|
+
el.onclick = null;
|
|
1445
|
+
el.onkeydown = null;
|
|
1446
|
+
// Blur BEFORE `aria-hidden` lands: hiding the focused element from
|
|
1447
|
+
// the accessibility tree strands the screen reader's cursor on a node
|
|
1448
|
+
// it can no longer describe.
|
|
1449
|
+
if (el.ownerDocument.activeElement === el)
|
|
1450
|
+
el.blur();
|
|
1451
|
+
el.removeAttribute('role');
|
|
1452
|
+
el.removeAttribute('tabindex');
|
|
1453
|
+
el.removeAttribute('aria-label');
|
|
1454
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1455
|
+
clearToastAnnouncement();
|
|
1456
|
+
}
|
|
1457
|
+
function showActionToast(text, kind, onDismiss) {
|
|
1279
1458
|
if (typeof document === 'undefined')
|
|
1280
1459
|
return;
|
|
1281
1460
|
const w = window;
|
|
@@ -1319,34 +1498,62 @@ function showActionToast(text, kind) {
|
|
|
1319
1498
|
el.textContent = text;
|
|
1320
1499
|
el.style.opacity = '1';
|
|
1321
1500
|
el.style.transform = 'translateX(-50%) translateY(0)';
|
|
1501
|
+
// Reset to the non-interactive posture every time, so a toast that
|
|
1502
|
+
// follows an `action_required` notice does not inherit its tab stop
|
|
1503
|
+
// and name. The `action_required` branch below re-applies them.
|
|
1504
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1505
|
+
el.removeAttribute('role');
|
|
1506
|
+
el.removeAttribute('tabindex');
|
|
1507
|
+
el.removeAttribute('aria-label');
|
|
1508
|
+
el.style.pointerEvents = 'none';
|
|
1509
|
+
el.onkeydown = null;
|
|
1510
|
+
announceToast(text, kind);
|
|
1322
1511
|
// Clear any prior auto-dismiss timer so a fresh pending toast
|
|
1323
1512
|
// doesn't get hidden mid-flight.
|
|
1324
1513
|
const elWithTimer = el;
|
|
1325
1514
|
if (elWithTimer.__toastTimer)
|
|
1326
1515
|
clearTimeout(elWithTimer.__toastTimer);
|
|
1327
|
-
// `action_required` PERSISTS until the user
|
|
1328
|
-
// (
|
|
1516
|
+
// `action_required` PERSISTS until the user dismisses the toast
|
|
1517
|
+
// (click, Enter or Space). Per MCP Apps spec, `ui/message` is a
|
|
1329
1518
|
// PREPARED user prompt — the host renders it into the chat input
|
|
1330
1519
|
// but the user must press send for it to reach the agent. So the
|
|
1331
1520
|
// toast can't auto-dismiss; doing so would imply the gesture went
|
|
1332
1521
|
// through when it actually requires a user follow-up.
|
|
1522
|
+
//
|
|
1523
|
+
// `onDismiss` fires on that click — the only signal a caller gets
|
|
1524
|
+
// that the user has read and closed a persistent notice. The relay
|
|
1525
|
+
// notice uses it to arm the post-dismissal cue (ggui#442).
|
|
1333
1526
|
if (kind === 'action_required') {
|
|
1334
|
-
|
|
1335
|
-
|
|
1527
|
+
// The dismissal is a real control, so it carries real control
|
|
1528
|
+
// semantics: a role, an accessible name that says what activating
|
|
1529
|
+
// it does, a tab stop, and the two keys a button responds to. The
|
|
1530
|
+
// announcement already carried the message; this carries the way
|
|
1531
|
+
// out of it.
|
|
1532
|
+
const target = el;
|
|
1533
|
+
target.removeAttribute('aria-hidden');
|
|
1534
|
+
target.setAttribute('role', 'button');
|
|
1535
|
+
target.setAttribute('tabindex', '0');
|
|
1536
|
+
target.setAttribute('aria-label', `${text} Activate to dismiss.`);
|
|
1537
|
+
target.style.pointerEvents = 'auto';
|
|
1538
|
+
const dismiss = () => {
|
|
1539
|
+
hideToast(target);
|
|
1540
|
+
onDismiss?.();
|
|
1541
|
+
};
|
|
1542
|
+
target.onclick = dismiss;
|
|
1543
|
+
target.onkeydown = (ev) => {
|
|
1544
|
+
if (ev.key !== 'Enter' && ev.key !== ' ')
|
|
1336
1545
|
return;
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1546
|
+
// Space scrolls the page by default; a button must not.
|
|
1547
|
+
ev.preventDefault();
|
|
1548
|
+
dismiss();
|
|
1340
1549
|
};
|
|
1341
1550
|
}
|
|
1342
1551
|
else {
|
|
1343
1552
|
el.onclick = null;
|
|
1344
1553
|
if (kind === 'success' || kind === 'fallback' || kind === 'error') {
|
|
1554
|
+
const target = el;
|
|
1345
1555
|
elWithTimer.__toastTimer = window.setTimeout(() => {
|
|
1346
|
-
|
|
1347
|
-
return;
|
|
1348
|
-
el.style.opacity = '0';
|
|
1349
|
-
el.style.transform = 'translateX(-50%) translateY(8px)';
|
|
1556
|
+
hideToast(target);
|
|
1350
1557
|
}, 2500);
|
|
1351
1558
|
}
|
|
1352
1559
|
}
|
|
@@ -1362,8 +1569,7 @@ function dismissActionToast() {
|
|
|
1362
1569
|
const el = document.getElementById('__ggui-action-toast__');
|
|
1363
1570
|
if (!el)
|
|
1364
1571
|
return;
|
|
1365
|
-
el
|
|
1366
|
-
el.style.transform = 'translateX(-50%) translateY(8px)';
|
|
1572
|
+
hideToast(el);
|
|
1367
1573
|
const elWithTimer = el;
|
|
1368
1574
|
if (elWithTimer.__toastTimer) {
|
|
1369
1575
|
clearTimeout(elWithTimer.__toastTimer);
|
|
@@ -1506,6 +1712,284 @@ function emitUserActionDoorbell(args) {
|
|
|
1506
1712
|
},
|
|
1507
1713
|
});
|
|
1508
1714
|
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Latch for the relay-incapability notice (ggui#440). A host that
|
|
1717
|
+
* cannot relay fails EVERY gesture, so the explanation is stated once
|
|
1718
|
+
* and left standing; without the latch the user gets one identical
|
|
1719
|
+
* toast per click, which is the #426 failure mode this closes.
|
|
1720
|
+
*
|
|
1721
|
+
* Cleared by ANY well-formed result envelope — `{ok:true}` and
|
|
1722
|
+
* `{ok:false}` alike, either proves the host can relay (see the
|
|
1723
|
+
* response-arrival guard in {@link dispatchSubmitAction}). Both
|
|
1724
|
+
* transition edges emit a `relay-incapability` observability event;
|
|
1725
|
+
* nothing else about the latch is observable to the host.
|
|
1726
|
+
*/
|
|
1727
|
+
let relayIncapabilityAnnounced = false;
|
|
1728
|
+
/**
|
|
1729
|
+
* Whether the user has manually dismissed the CURRENTLY-STANDING relay
|
|
1730
|
+
* notice (ggui#442).
|
|
1731
|
+
*
|
|
1732
|
+
* "Dismissed the one explanation" is not "wants zero feedback forever",
|
|
1733
|
+
* but it IS consent that the explanation itself has been read. So the
|
|
1734
|
+
* dead zone this opens is filled with a per-gesture CUE, never with the
|
|
1735
|
+
* notice again.
|
|
1736
|
+
*
|
|
1737
|
+
* Scoped to the notice, not to toasts in general: the doorbell's
|
|
1738
|
+
* `action_required` toast is a different message with a different
|
|
1739
|
+
* dismissal meaning, so the flag is set from the relay notice's own
|
|
1740
|
+
* `onDismiss` callback rather than from `showActionToast` at large.
|
|
1741
|
+
*
|
|
1742
|
+
* Reset on BOTH latch edges — a re-latch shows a fresh notice nobody
|
|
1743
|
+
* has dismissed yet, and a self-heal must leave no residue behind.
|
|
1744
|
+
*/
|
|
1745
|
+
let relayNoticeDismissed = false;
|
|
1746
|
+
/**
|
|
1747
|
+
* Timestamp of the last fallback cue toast, for the throttle below.
|
|
1748
|
+
* Reset alongside the latch so a new dead zone never inherits an old
|
|
1749
|
+
* one's quiet period.
|
|
1750
|
+
*/
|
|
1751
|
+
let lastRelayCueToastAt = 0;
|
|
1752
|
+
/**
|
|
1753
|
+
* Timestamp of the last SPOKEN cue (ggui#447), on its own clock.
|
|
1754
|
+
*
|
|
1755
|
+
* Separate from the toast clock because the two cue shapes are
|
|
1756
|
+
* mutually exclusive per gesture — a gesture either pulses a focused
|
|
1757
|
+
* control or falls back to the toast — and each needs its own quiet
|
|
1758
|
+
* period to be the one it advertises.
|
|
1759
|
+
*/
|
|
1760
|
+
let lastRelayCueAnnouncedAt = 0;
|
|
1761
|
+
/**
|
|
1762
|
+
* Intent named by the standing spoken cue, or `null` when none is.
|
|
1763
|
+
*
|
|
1764
|
+
* The throttle is keyed on this as well as the clock. A quiet period
|
|
1765
|
+
* that ignores WHICH action was attempted does not merely withhold the
|
|
1766
|
+
* second cue — it leaves the region still naming the FIRST one, so a
|
|
1767
|
+
* screen reader asked to re-read it reports the wrong action as the
|
|
1768
|
+
* thing that just failed. Silence is a defensible answer to a repeat;
|
|
1769
|
+
* it is never a defensible answer to a different gesture.
|
|
1770
|
+
*/
|
|
1771
|
+
let lastRelayCueAnnouncedIntent = null;
|
|
1772
|
+
/**
|
|
1773
|
+
* Expiry timer for the standing spoken cue, held so a repeat can cancel
|
|
1774
|
+
* its predecessor instead of racing it.
|
|
1775
|
+
*/
|
|
1776
|
+
let relayCueAnnounceTimer;
|
|
1777
|
+
/** Class carrying the pulse animation; also the `@keyframes` name. */
|
|
1778
|
+
const RELAY_CUE_CLASS = 'ggui-relay-cue-pulse';
|
|
1779
|
+
/** Stable id for the injected `<style>` — idempotent per document. */
|
|
1780
|
+
const RELAY_CUE_STYLE_ID = 'ggui-relay-cue-style';
|
|
1781
|
+
/** Pulse duration (ms). Brief enough to read as acknowledgement. */
|
|
1782
|
+
const RELAY_CUE_DURATION_MS = 400;
|
|
1783
|
+
/** At most one fallback cue toast, and one spoken cue, per window (ms). */
|
|
1784
|
+
const RELAY_CUE_THROTTLE_MS = 5_000;
|
|
1785
|
+
/**
|
|
1786
|
+
* How long a spoken cue stands before it is retracted (ms).
|
|
1787
|
+
*
|
|
1788
|
+
* Matches the toast's own auto-dismiss cadence, and deliberately
|
|
1789
|
+
* outlasts the 400ms pulse by a wide margin: content pulled out of a
|
|
1790
|
+
* live region too soon after it lands can be dropped before the screen
|
|
1791
|
+
* reader gets to it, so retracting at pulse-end would trade a stale
|
|
1792
|
+
* announcement for a missing one.
|
|
1793
|
+
*/
|
|
1794
|
+
const RELAY_CUE_ANNOUNCE_TTL_MS = 2_500;
|
|
1795
|
+
/**
|
|
1796
|
+
* Drop the retraction a standing spoken cue has pending, because
|
|
1797
|
+
* something newer is about to own the region — another cue, a toast, or
|
|
1798
|
+
* a latch edge that ends the dead zone entirely. Left armed, it comes
|
|
1799
|
+
* due mid-message and clears whatever is in the region then.
|
|
1800
|
+
*/
|
|
1801
|
+
function cancelRelayCueRetraction() {
|
|
1802
|
+
if (relayCueAnnounceTimer === undefined)
|
|
1803
|
+
return;
|
|
1804
|
+
clearTimeout(relayCueAnnounceTimer);
|
|
1805
|
+
relayCueAnnounceTimer = undefined;
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Give the dead zone a fresh quiet period. Called on both latch edges
|
|
1809
|
+
* and from the test reset — every throttle belongs to the notice that
|
|
1810
|
+
* is standing NOW, so a new one never inherits an old one's silence.
|
|
1811
|
+
*/
|
|
1812
|
+
function resetRelayCueThrottles() {
|
|
1813
|
+
lastRelayCueToastAt = 0;
|
|
1814
|
+
lastRelayCueAnnouncedAt = 0;
|
|
1815
|
+
lastRelayCueAnnouncedIntent = null;
|
|
1816
|
+
cancelRelayCueRetraction();
|
|
1817
|
+
}
|
|
1818
|
+
/** @internal — exported for unit tests to reset module state. */
|
|
1819
|
+
export function __resetRelayNoticeForTest() {
|
|
1820
|
+
relayIncapabilityAnnounced = false;
|
|
1821
|
+
relayNoticeDismissed = false;
|
|
1822
|
+
resetRelayCueThrottles();
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Inject the pulse keyframes once per document. Mirrors how
|
|
1826
|
+
* `react-renderer.ts` injects its theme CSS — `<style>` with a stable
|
|
1827
|
+
* id on `<head>`, idempotent on repeat calls.
|
|
1828
|
+
*
|
|
1829
|
+
* Lazy rather than at boot: a session that never enters the dead zone
|
|
1830
|
+
* never pays for the rule.
|
|
1831
|
+
*/
|
|
1832
|
+
function ensureRelayCueStyle() {
|
|
1833
|
+
if (document.getElementById(RELAY_CUE_STYLE_ID) !== null)
|
|
1834
|
+
return;
|
|
1835
|
+
const style = document.createElement('style');
|
|
1836
|
+
style.id = RELAY_CUE_STYLE_ID;
|
|
1837
|
+
// Opacity only. Anything touching geometry (scale, translate, outline
|
|
1838
|
+
// width) would have to make assumptions about the generated markup it
|
|
1839
|
+
// lands on — this cue is applied to whatever the user happened to
|
|
1840
|
+
// focus, so it must be incapable of disturbing any layout.
|
|
1841
|
+
style.textContent =
|
|
1842
|
+
`@keyframes ${RELAY_CUE_CLASS}{0%,100%{opacity:1}50%{opacity:.45}}` +
|
|
1843
|
+
`.${RELAY_CUE_CLASS}{animation:${RELAY_CUE_CLASS} ` +
|
|
1844
|
+
`${RELAY_CUE_DURATION_MS}ms ease-in-out}`;
|
|
1845
|
+
document.head.appendChild(style);
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* The element to pulse, or `null` when there isn't a usable one.
|
|
1849
|
+
*
|
|
1850
|
+
* Usable means: a real focused element that belongs to the render. A
|
|
1851
|
+
* document with nothing focused reports `document.body`, which is not a
|
|
1852
|
+
* control the user just pressed; and anything outside
|
|
1853
|
+
* `[data-ggui-session-root]` (host chrome, the toast itself) is not
|
|
1854
|
+
* ours to animate. Both fall through to the toast.
|
|
1855
|
+
*
|
|
1856
|
+
* The root ITSELF is excluded too. `contains` is reflexive, so a
|
|
1857
|
+
* focused session root would otherwise pulse the whole render — the
|
|
1858
|
+
* opposite of a cue pointing at one control. That is reachable: the
|
|
1859
|
+
* root is reused if one already exists in the document (see
|
|
1860
|
+
* `ensureStatusDom`), so a host or a generated tree can hand it a
|
|
1861
|
+
* `tabindex` and focus it.
|
|
1862
|
+
*/
|
|
1863
|
+
function resolveRelayCueTarget() {
|
|
1864
|
+
const active = document.activeElement;
|
|
1865
|
+
if (active === null || active === document.body)
|
|
1866
|
+
return null;
|
|
1867
|
+
const root = document.querySelector('[data-ggui-session-root]');
|
|
1868
|
+
if (root === null || active === root || !root.contains(active))
|
|
1869
|
+
return null;
|
|
1870
|
+
return active;
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Speak the pulse (ggui#447).
|
|
1874
|
+
*
|
|
1875
|
+
* The pulse animates opacity on a control the runtime does not own, so
|
|
1876
|
+
* it carries nothing to assistive technology and nothing may be added
|
|
1877
|
+
* to that control — a role or label written onto generated markup would
|
|
1878
|
+
* be a lie about someone else's element. The honest counterpart is a
|
|
1879
|
+
* message in the announcer's own region, saying what the pulse means.
|
|
1880
|
+
*
|
|
1881
|
+
* NOT gated on `__GGUI_TOAST_DISABLED__`: that flag suppresses our
|
|
1882
|
+
* toast chrome, and the pulse is not toast chrome — it fires either
|
|
1883
|
+
* way. Announcing on the same condition as the visual keeps the two
|
|
1884
|
+
* cues telling the same story.
|
|
1885
|
+
*
|
|
1886
|
+
* Throttled on its own clock, unlike the pulse — but only against
|
|
1887
|
+
* IDENTICAL repeats. A flash repeating on every gesture costs a sighted
|
|
1888
|
+
* user nothing to ignore, while the same sentence read aloud each time
|
|
1889
|
+
* buries whatever else the screen reader was saying and carries no new
|
|
1890
|
+
* information. A DIFFERENT action is new information: withholding it
|
|
1891
|
+
* would leave the region naming the previous gesture, which reads as a
|
|
1892
|
+
* confident answer about the wrong thing. The two cues share a meaning,
|
|
1893
|
+
* not a rate.
|
|
1894
|
+
*
|
|
1895
|
+
* The cue also expires. A live region is a description of what is
|
|
1896
|
+
* happening now, and a 400ms pulse that leaves a sentence standing for
|
|
1897
|
+
* the rest of the session is the same stale-announcement defect the
|
|
1898
|
+
* toast half avoids by clearing on hide.
|
|
1899
|
+
*/
|
|
1900
|
+
function announceRelayCue(intent) {
|
|
1901
|
+
const now = Date.now();
|
|
1902
|
+
if (intent === lastRelayCueAnnouncedIntent &&
|
|
1903
|
+
now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
lastRelayCueAnnouncedAt = now;
|
|
1907
|
+
lastRelayCueAnnouncedIntent = intent;
|
|
1908
|
+
// `announceToast` cancels any retraction still pending, so the timer
|
|
1909
|
+
// armed here is always the only one in flight.
|
|
1910
|
+
announceToast(`⚠ ${intent} — not delivered`, 'error');
|
|
1911
|
+
relayCueAnnounceTimer = window.setTimeout(() => {
|
|
1912
|
+
relayCueAnnounceTimer = undefined;
|
|
1913
|
+
retractRelayCueAnnouncement();
|
|
1914
|
+
}, RELAY_CUE_ANNOUNCE_TTL_MS);
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Take a spoken cue back down once it has been heard.
|
|
1918
|
+
*
|
|
1919
|
+
* Unconditional, because by the time it runs the region can only hold
|
|
1920
|
+
* this cue's own sentence or nothing at all: every other write to the
|
|
1921
|
+
* region goes through {@link announceToast}, which cancels this
|
|
1922
|
+
* retraction before it speaks.
|
|
1923
|
+
*
|
|
1924
|
+
* That cancellation is the whole mechanism — comparing text here could
|
|
1925
|
+
* not substitute for it. The pulse cue and the fallback cue toast build
|
|
1926
|
+
* their sentences from the SAME template, so for one intent the two are
|
|
1927
|
+
* character-identical and no comparison can tell a dead cue from a live
|
|
1928
|
+
* toast.
|
|
1929
|
+
*/
|
|
1930
|
+
function retractRelayCueAnnouncement() {
|
|
1931
|
+
if (typeof document === 'undefined')
|
|
1932
|
+
return;
|
|
1933
|
+
const regions = ensureToastAnnouncer(document);
|
|
1934
|
+
if (regions === null)
|
|
1935
|
+
return;
|
|
1936
|
+
regions.assertive.textContent = '';
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* The dead-zone cue (ggui#442): the smallest honest "that did nothing"
|
|
1940
|
+
* signal, for a gesture on a host already known — and already
|
|
1941
|
+
* explained — to be relay-incapable.
|
|
1942
|
+
*
|
|
1943
|
+
* Component-agnostic by construction. It pulses whatever the user
|
|
1944
|
+
* focused without knowing what that is, and falls back to the toast
|
|
1945
|
+
* primitive when there is nothing to pulse. Either shape also SPEAKS
|
|
1946
|
+
* (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
|
|
1947
|
+
* the toast primitive's own announcement.
|
|
1948
|
+
*/
|
|
1949
|
+
function showPostDismissalCue(intent) {
|
|
1950
|
+
if (typeof document === 'undefined')
|
|
1951
|
+
return;
|
|
1952
|
+
const target = resolveRelayCueTarget();
|
|
1953
|
+
if (target !== null) {
|
|
1954
|
+
// A pulse already running on this element IS the acknowledgement
|
|
1955
|
+
// for this gesture too. Re-adding a present class would not restart
|
|
1956
|
+
// the animation anyway (that needs a reflow), and racing two
|
|
1957
|
+
// removal timers on one element is how a class gets stranded.
|
|
1958
|
+
if (target.classList.contains(RELAY_CUE_CLASS))
|
|
1959
|
+
return;
|
|
1960
|
+
ensureRelayCueStyle();
|
|
1961
|
+
target.classList.add(RELAY_CUE_CLASS);
|
|
1962
|
+
announceRelayCue(intent);
|
|
1963
|
+
// Both cleanup routes cancel the other. Without the `clearTimeout`,
|
|
1964
|
+
// a pulse ended early by `animationend` leaves its timer armed, and
|
|
1965
|
+
// that timer later strips whatever class is on the element THEN —
|
|
1966
|
+
// truncating the next pulse partway through. The window is the gap
|
|
1967
|
+
// between the two, which widens on any host that ends the
|
|
1968
|
+
// animation early.
|
|
1969
|
+
const clear = () => {
|
|
1970
|
+
target.classList.remove(RELAY_CUE_CLASS);
|
|
1971
|
+
target.removeEventListener('animationend', clear);
|
|
1972
|
+
// `timer` is declared below; `clear` only ever RUNS after that
|
|
1973
|
+
// line, so it always reads an initialized id.
|
|
1974
|
+
clearTimeout(timer);
|
|
1975
|
+
};
|
|
1976
|
+
target.addEventListener('animationend', clear, { once: true });
|
|
1977
|
+
// Belt for real browsers, braces for everything that never fires
|
|
1978
|
+
// `animationend`: jsdom, a display:none subtree, and any host whose
|
|
1979
|
+
// reduced-motion policy skips the animation outright.
|
|
1980
|
+
const timer = window.setTimeout(clear, RELAY_CUE_DURATION_MS + 100);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
const now = Date.now();
|
|
1984
|
+
if (now - lastRelayCueToastAt < RELAY_CUE_THROTTLE_MS)
|
|
1985
|
+
return;
|
|
1986
|
+
lastRelayCueToastAt = now;
|
|
1987
|
+
// The toast primitive's auto-dismissing variant — this is a cue, not
|
|
1988
|
+
// a second explanation, so it must clear itself. Throttled because a
|
|
1989
|
+
// per-click toast on a host that fails every click is the #426
|
|
1990
|
+
// failure mode the latch exists to prevent.
|
|
1991
|
+
showActionToast(`⚠ ${intent} — not delivered`, 'error');
|
|
1992
|
+
}
|
|
1509
1993
|
/**
|
|
1510
1994
|
* Dispatch a submit-action via the empirically-validated bridge
|
|
1511
1995
|
* chain (validated against claude.ai): a `tools/call` to
|
|
@@ -1551,7 +2035,24 @@ export function dispatchSubmitAction(args) {
|
|
|
1551
2035
|
// immediately so they know the click was registered, even when
|
|
1552
2036
|
// submit_action's HTTP round-trip takes a moment. State updates
|
|
1553
2037
|
// when the response (or fallback) lands.
|
|
1554
|
-
|
|
2038
|
+
//
|
|
2039
|
+
// Skipped once the relay-incapability notice has latched (ggui#440):
|
|
2040
|
+
// we already know this host can't relay, so a fresh "sending…" flash
|
|
2041
|
+
// would clobber the persistent explanation with a transient state
|
|
2042
|
+
// that nothing downstream ever restores — the opposite of "left
|
|
2043
|
+
// standing" (see the latch declaration + terminal branch below).
|
|
2044
|
+
//
|
|
2045
|
+
// Once that explanation has been DISMISSED, though, skipping leaves
|
|
2046
|
+
// the gesture with no feedback at all — the dead zone ggui#442
|
|
2047
|
+
// names. The cue fills it here, at dispatch time, because the
|
|
2048
|
+
// element it pulses is the one the user has focused NOW; by the time
|
|
2049
|
+
// the relay response settles, focus may have moved on.
|
|
2050
|
+
if (!relayIncapabilityAnnounced) {
|
|
2051
|
+
showActionToast(`→ ${intent}${dataPart}`, 'pending');
|
|
2052
|
+
}
|
|
2053
|
+
else if (relayNoticeDismissed) {
|
|
2054
|
+
showPostDismissalCue(intent);
|
|
2055
|
+
}
|
|
1555
2056
|
// (2) Try submit_action via host relay. Spec-compliant hosts
|
|
1556
2057
|
// forward the tools/call to the MCP server's submit_action handler;
|
|
1557
2058
|
// the pipe entry lands with `id: actionId`. On success, branch on
|
|
@@ -1590,10 +2091,60 @@ export function dispatchSubmitAction(args) {
|
|
|
1590
2091
|
showActionToast(`⚠ ${intent} — transport error`, 'error');
|
|
1591
2092
|
resp = null;
|
|
1592
2093
|
}
|
|
2094
|
+
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
2095
|
+
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
2096
|
+
// (an otherwise-healthy relay, expired pipe) proves it exactly as
|
|
2097
|
+
// much as `{ok:true}` — so any earlier (possibly false — see
|
|
2098
|
+
// `isRelayShapedFailure`) "cannot relay" latch no longer holds.
|
|
2099
|
+
// Cleared HERE, at response arrival, so the success path and the
|
|
2100
|
+
// `{ok:false}` path below both release it; clearing also
|
|
2101
|
+
// un-freezes `channelToolsCall`'s guard, which reads this same
|
|
2102
|
+
// latch. Transition-edged: acts (and emits the observability
|
|
2103
|
+
// edge) only when a latch was actually standing. No oscillation
|
|
2104
|
+
// within one dispatch — the latch-set branch below requires
|
|
2105
|
+
// `isRelayShapedFailure(resp)`, which this guard excludes.
|
|
2106
|
+
if (relayIncapabilityAnnounced &&
|
|
2107
|
+
resp !== null &&
|
|
2108
|
+
!isRelayShapedFailure(resp)) {
|
|
2109
|
+
relayIncapabilityAnnounced = false;
|
|
2110
|
+
// Self-heal leaves NO cue residue (ggui#442): the dead zone is
|
|
2111
|
+
// gone, so the state that armed the cue goes with it. A later
|
|
2112
|
+
// re-latch starts from a clean slate.
|
|
2113
|
+
relayNoticeDismissed = false;
|
|
2114
|
+
resetRelayCueThrottles();
|
|
2115
|
+
postObservabilityToParent({
|
|
2116
|
+
kind: 'relay-incapability',
|
|
2117
|
+
state: 'cleared',
|
|
2118
|
+
});
|
|
2119
|
+
// The (1.5) pending toast above was SKIPPED for this gesture —
|
|
2120
|
+
// the latch was still standing at dispatch time — so a bare
|
|
2121
|
+
// clear leaves the now-false "cannot relay" notice standing with
|
|
2122
|
+
// no successor until a `drain_ack` frame that may never arrive
|
|
2123
|
+
// in MCP-Apps relay contexts. On the success sub-case, replace
|
|
2124
|
+
// it with the ordinary pending toast so the drain_ack dismissal
|
|
2125
|
+
// chain below has a predecessor to dismiss. No equivalent is
|
|
2126
|
+
// needed on the {ok:false} sub-case: that path always falls
|
|
2127
|
+
// through to the terminal "could not reach the agent" toast a
|
|
2128
|
+
// few lines down, which already replaces the stale notice.
|
|
2129
|
+
if (classifySubmitActionResponse(resp) === 'success') {
|
|
2130
|
+
showActionToast(`→ ${intent}${dataPart}`, 'pending');
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1593
2133
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
1594
2134
|
const consumerPresent = extractConsumerPresent(resp);
|
|
1595
2135
|
if (consumerPresent === false) {
|
|
1596
|
-
|
|
2136
|
+
// No `ggui_consume` long-poll is draining this render's pipe,
|
|
2137
|
+
// so the gesture needs a fresh agent turn. `ui/message` is the
|
|
2138
|
+
// ONLY view→host method that can start one — a host that does
|
|
2139
|
+
// not accept it cannot be woken by us at all, and the user has
|
|
2140
|
+
// to send a message themselves (ggui#440).
|
|
2141
|
+
//
|
|
2142
|
+
// The doorbell is posted either way: capability absence is an
|
|
2143
|
+
// unconfirmed signal, not a proven "cannot", and a dropped
|
|
2144
|
+
// postMessage costs nothing.
|
|
2145
|
+
showActionToast(hostCanReceiveMessages()
|
|
2146
|
+
? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
|
|
2147
|
+
: `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
|
|
1597
2148
|
emitUserActionDoorbell({
|
|
1598
2149
|
intent,
|
|
1599
2150
|
sessionId,
|
|
@@ -1607,12 +2158,135 @@ export function dispatchSubmitAction(args) {
|
|
|
1607
2158
|
// it when ggui_consume drains the event.
|
|
1608
2159
|
return;
|
|
1609
2160
|
}
|
|
1610
|
-
// Enqueue failed (pipe gone / transport error
|
|
1611
|
-
// on any pipe, so a doorbell would point at an
|
|
1612
|
-
//
|
|
2161
|
+
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
2162
|
+
// The gesture is not on any pipe, so a doorbell would point at an
|
|
2163
|
+
// empty queue; no `ui/message` is emitted either way.
|
|
2164
|
+
//
|
|
2165
|
+
// Separate the STRUCTURAL case from the transient one (ggui#440):
|
|
2166
|
+
// when the host never advertised `serverTools`, every gesture will
|
|
2167
|
+
// fail identically, so explain it once, persistently, instead of
|
|
2168
|
+
// one identical error toast per click. Latching requires ALL of:
|
|
2169
|
+
//
|
|
2170
|
+
// - `hostCapabilitiesCaptured()` — the handshake has actually
|
|
2171
|
+
// resolved. Before that, capability absence is "not asked
|
|
2172
|
+
// yet", not "advertised nothing"; a gesture that fails in the
|
|
2173
|
+
// mount-to-handshake window would otherwise falsely latch a
|
|
2174
|
+
// fully-capable host that just hasn't finished connecting.
|
|
2175
|
+
// - `!hostCanRelayToolCalls()` — the host never advertised
|
|
2176
|
+
// `serverTools`.
|
|
2177
|
+
// - `isRelayShapedFailure(resp)` — NO well-formed result
|
|
2178
|
+
// envelope arrived. A `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
2179
|
+
// result (the common expired-pipe case) is proof the host DID
|
|
2180
|
+
// relay the call there and back; latching on that would falsely
|
|
2181
|
+
// brand a working relay as incapable and freeze the channel
|
|
2182
|
+
// router pre-attempt. That case has already CLEARED any
|
|
2183
|
+
// standing latch at the response-arrival guard above, and
|
|
2184
|
+
// keeps routing to the ordinary per-gesture transient toast
|
|
2185
|
+
// below instead.
|
|
2186
|
+
if (hostCapabilitiesCaptured() &&
|
|
2187
|
+
!hostCanRelayToolCalls() &&
|
|
2188
|
+
isRelayShapedFailure(resp)) {
|
|
2189
|
+
if (!relayIncapabilityAnnounced) {
|
|
2190
|
+
relayIncapabilityAnnounced = true;
|
|
2191
|
+
// Transition edge — the paired 'cleared' edge lives at the
|
|
2192
|
+
// response-arrival guard above. NEVER emit off-edge (e.g. per
|
|
2193
|
+
// channel poll tick): the router ticks on an interval and
|
|
2194
|
+
// would spam; the two edges carry the full information.
|
|
2195
|
+
postObservabilityToParent({
|
|
2196
|
+
kind: 'relay-incapability',
|
|
2197
|
+
state: 'latched',
|
|
2198
|
+
});
|
|
2199
|
+
// Establish the flag's invariant where the notice it describes
|
|
2200
|
+
// is created: a freshly-shown notice is undismissed, and its
|
|
2201
|
+
// dead zone starts with an unspent throttle. Today every
|
|
2202
|
+
// re-latch already passes through the clear edge above (which
|
|
2203
|
+
// resets both), so this is the same value arriving by a second
|
|
2204
|
+
// route — kept because the state belongs to THIS notice, and a
|
|
2205
|
+
// future second latch-set path shouldn't have to know that.
|
|
2206
|
+
relayNoticeDismissed = false;
|
|
2207
|
+
resetRelayCueThrottles();
|
|
2208
|
+
showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required', () => {
|
|
2209
|
+
relayNoticeDismissed = true;
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
1613
2214
|
showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
|
|
1614
2215
|
})();
|
|
1615
2216
|
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Channel-transport router's `tools/call` invoker (ggui#440) —
|
|
2219
|
+
* iframe-polling transport, `tools/call` against the parent MCP host
|
|
2220
|
+
* via `app.callServerTool` (spec-canonical) when the App handle is
|
|
2221
|
+
* set; falls back to raw postMessage pre-handshake or in tests.
|
|
2222
|
+
* Direct call (no LLM consent loop). Returns the tool's
|
|
2223
|
+
* structuredContent (or `content[0]` if that's where the payload
|
|
2224
|
+
* landed) as a `JsonValue`. On RPC error we throw — the router
|
|
2225
|
+
* catches and silently retries on the next tick.
|
|
2226
|
+
*
|
|
2227
|
+
* Once a REAL gesture has confirmed this host can't relay (the latch
|
|
2228
|
+
* above — set only after {@link dispatchSubmitAction} actually tried
|
|
2229
|
+
* and failed on a host with no `serverTools` advertised, with
|
|
2230
|
+
* capabilities already captured, AND the failure was relay-shaped —
|
|
2231
|
+
* not a well-formed `{ok:false}` result; see `isRelayShapedFailure`),
|
|
2232
|
+
* every subsequent poll will fail identically.
|
|
2233
|
+
*
|
|
2234
|
+
* This guard does NOT stop the router's poll loop, and nothing here
|
|
2235
|
+
* should ever be written so that it could (ggui#443). It does two
|
|
2236
|
+
* things. It makes each doomed tick CHEAP — the poll throws before
|
|
2237
|
+
* the `callServerToolSpec` round-trip. And, by throwing the typed
|
|
2238
|
+
* {@link RelayIncapableError} rather than a bare `Error`, it tells
|
|
2239
|
+
* the router WHICH KIND of failure this is: `channel-transport.ts`'s
|
|
2240
|
+
* tick classifies structural rejections apart from transient ones and
|
|
2241
|
+
* drops the channel to a slow probe cadence, emitting one
|
|
2242
|
+
* `channel-poll-degraded` event per transition. The first poll that
|
|
2243
|
+
* succeeds after the latch clears restores the normal cadence and
|
|
2244
|
+
* emits `channel-poll-recovered`.
|
|
2245
|
+
*
|
|
2246
|
+
* The channel keeps probing throughout, which is what makes that
|
|
2247
|
+
* recovery possible: the latch clears only when a call succeeds, and
|
|
2248
|
+
* only an attempt can discover that. A channel stopped on structural
|
|
2249
|
+
* failure could never come back.
|
|
2250
|
+
*
|
|
2251
|
+
* Keyed on the LATCH, not on `hostCanRelayToolCalls()` directly: raw
|
|
2252
|
+
* advertisement absence never changes after boot, so gating on it
|
|
2253
|
+
* here would throw pre-attempt for the LIFE OF THE SESSION on any
|
|
2254
|
+
* host that under-advertises but genuinely proxies `tools/call`
|
|
2255
|
+
* (ggui's own embed host pre-Task-2 is exactly this case) — silently
|
|
2256
|
+
* killing working channel polls forever. That is "absence of a
|
|
2257
|
+
* capability blocks an attempt", which the fail-safe constraint
|
|
2258
|
+
* forbids. The latch implies `!hostCanRelayToolCalls()` (it only
|
|
2259
|
+
* ever sets inside that guard), so testing it alone is sufficient —
|
|
2260
|
+
* a host that never fails a real gesture never latches, and its
|
|
2261
|
+
* channels keep working.
|
|
2262
|
+
*
|
|
2263
|
+
* Extracted to a named export (rather than an inline closure inside
|
|
2264
|
+
* `bootProduction`) so unit tests can drive it directly through the
|
|
2265
|
+
* lightweight `MockTransport` harness — `bootProduction` dynamic-
|
|
2266
|
+
* imports the full react/design/wire module graph, which is too
|
|
2267
|
+
* heavy to exercise from a spec file (see
|
|
2268
|
+
* `boot-production-context.test.ts`'s docstring for precedent).
|
|
2269
|
+
*
|
|
2270
|
+
* @internal — exported for unit tests + production reuse.
|
|
2271
|
+
*/
|
|
2272
|
+
export async function channelToolsCall(args) {
|
|
2273
|
+
if (relayIncapabilityAnnounced) {
|
|
2274
|
+
throw new RelayIncapableError();
|
|
2275
|
+
}
|
|
2276
|
+
const resp = await callServerToolSpec(args.toolName, args.args);
|
|
2277
|
+
if (resp.error !== undefined) {
|
|
2278
|
+
throw new Error(resp.error.message ?? 'tools/call failed');
|
|
2279
|
+
}
|
|
2280
|
+
const result = resp.result;
|
|
2281
|
+
if (result === null || typeof result !== 'object') {
|
|
2282
|
+
return null;
|
|
2283
|
+
}
|
|
2284
|
+
const structured = result
|
|
2285
|
+
.structuredContent;
|
|
2286
|
+
if (structured !== undefined)
|
|
2287
|
+
return structured;
|
|
2288
|
+
return result;
|
|
2289
|
+
}
|
|
1616
2290
|
/**
|
|
1617
2291
|
* Resolve the name of the spec-canonical submit-action receiver tool
|
|
1618
2292
|
* the dispatch path targets with `tools/call`.
|
|
@@ -2397,28 +3071,11 @@ async function bootProduction(opts) {
|
|
|
2397
3071
|
}
|
|
2398
3072
|
: {}),
|
|
2399
3073
|
send: (msg) => manager.send(msg),
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
// loop). Returns the tool's structuredContent (or
|
|
2406
|
-
// `content[0]` if that's where the payload landed) as a
|
|
2407
|
-
// JsonValue. On RPC error we throw — the router catches
|
|
2408
|
-
// and silently retries on the next tick.
|
|
2409
|
-
const resp = await callServerToolSpec(toolName, args);
|
|
2410
|
-
if (resp.error !== undefined) {
|
|
2411
|
-
throw new Error(resp.error.message ?? 'tools/call failed');
|
|
2412
|
-
}
|
|
2413
|
-
const result = resp.result;
|
|
2414
|
-
if (result === null || typeof result !== 'object') {
|
|
2415
|
-
return null;
|
|
2416
|
-
}
|
|
2417
|
-
const structured = result.structuredContent;
|
|
2418
|
-
if (structured !== undefined)
|
|
2419
|
-
return structured;
|
|
2420
|
-
return result;
|
|
2421
|
-
},
|
|
3074
|
+
// See `channelToolsCall`'s docstring (defined alongside
|
|
3075
|
+
// `dispatchSubmitAction`, its sibling consumer of the
|
|
3076
|
+
// relay-incapability latch) for the fail-fast + fail-safe
|
|
3077
|
+
// rationale (ggui#440).
|
|
3078
|
+
toolsCall: channelToolsCall,
|
|
2422
3079
|
streamBus,
|
|
2423
3080
|
...(onObserve !== undefined
|
|
2424
3081
|
? {
|
|
@@ -2475,9 +3132,6 @@ async function bootProduction(opts) {
|
|
|
2475
3132
|
channelRegistry.register(createChannelErrorHandler({
|
|
2476
3133
|
getChannelTransport: () => channelTransport,
|
|
2477
3134
|
}));
|
|
2478
|
-
channelRegistry.register(createSystemHandler({
|
|
2479
|
-
...(onObserve !== undefined ? { onObserve } : {}),
|
|
2480
|
-
}));
|
|
2481
3135
|
return {
|
|
2482
3136
|
rootWireConfig: rootConfig,
|
|
2483
3137
|
streamBus,
|