@guuey/chat 0.10.0 → 0.12.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +134 -35
- package/dist/policy.d.ts +9 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +1 -1
- package/dist/react/components.d.ts +1 -1
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +3 -2
- package/dist/react/guuey-chat.d.ts +32 -2
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +167 -16
- package/dist/react/structural-identity.d.ts +3 -0
- package/dist/react/structural-identity.d.ts.map +1 -0
- package/dist/react/structural-identity.js +45 -0
- package/dist/react/use-transcript.d.ts.map +1 -1
- package/dist/react/use-transcript.js +5 -3
- package/dist/strings.d.ts +4 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +2 -0
- package/dist/types.d.ts +62 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/corpus/__snapshots__/corpus.test.ts.snap +269 -0
- package/src/index.ts +1 -0
- package/src/plan.ts +133 -37
- package/src/policy.ts +10 -3
- package/src/react/components.tsx +7 -2
- package/src/react/guuey-chat.tsx +218 -15
- package/src/react/structural-identity.ts +47 -0
- package/src/react/use-transcript.ts +5 -2
- package/src/strings.ts +6 -0
- package/src/types.ts +61 -5
- package/styles.css +10 -0
package/src/react/components.tsx
CHANGED
|
@@ -106,6 +106,7 @@ export type ViewSlotProps = Pick<
|
|
|
106
106
|
| "hostInfo"
|
|
107
107
|
| "hostContext"
|
|
108
108
|
| "onCallTool"
|
|
109
|
+
| "onUpdateModelContext"
|
|
109
110
|
| "onReadResource"
|
|
110
111
|
| "onSizeChanged"
|
|
111
112
|
| "negotiationTimeoutMs"
|
|
@@ -387,9 +388,12 @@ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode
|
|
|
387
388
|
* `onViewRef` (keyboard-accessible for free); a plain label otherwise.
|
|
388
389
|
*/
|
|
389
390
|
export function DefaultViewRef({ item, ctx }: ItemProps<ViewRefItem>): ReactNode {
|
|
391
|
+
const stateClasses = `${item.selected ? " guuey-chat-view-ref-selected" : ""}${
|
|
392
|
+
item.phase === "expired" ? " guuey-chat-view-ref-expired" : ""
|
|
393
|
+
}`;
|
|
390
394
|
if (ctx.onViewRef === undefined) {
|
|
391
395
|
return (
|
|
392
|
-
<p className=
|
|
396
|
+
<p className={`guuey-chat-view-ref${stateClasses}`} role="note">
|
|
393
397
|
{item.label}
|
|
394
398
|
</p>
|
|
395
399
|
);
|
|
@@ -398,7 +402,8 @@ export function DefaultViewRef({ item, ctx }: ItemProps<ViewRefItem>): ReactNode
|
|
|
398
402
|
return (
|
|
399
403
|
<button
|
|
400
404
|
type="button"
|
|
401
|
-
className=
|
|
405
|
+
className={`guuey-chat-view-ref guuey-chat-view-ref-button${stateClasses}`}
|
|
406
|
+
aria-pressed={item.selected}
|
|
402
407
|
onClick={() => onViewRef(item)}
|
|
403
408
|
>
|
|
404
409
|
{item.label}
|
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
forwardRef,
|
|
49
49
|
useCallback,
|
|
50
50
|
useEffect,
|
|
51
|
+
useId,
|
|
51
52
|
useImperativeHandle,
|
|
52
53
|
useMemo,
|
|
53
54
|
useRef,
|
|
@@ -56,23 +57,61 @@ import {
|
|
|
56
57
|
type ReactNode,
|
|
57
58
|
} from "react";
|
|
58
59
|
import {
|
|
60
|
+
createUiActionRelay,
|
|
59
61
|
createUiResourceReader,
|
|
60
62
|
createWebAdapters,
|
|
61
63
|
type AgentInvokeAdapters,
|
|
62
64
|
} from "@guuey/agent-client";
|
|
63
65
|
import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
|
|
64
66
|
import { useAgentInvoke } from "@guuey/agent-client/react";
|
|
65
|
-
import
|
|
67
|
+
import { unavailableToolCallResult } from "@guuey/mcp-apps-host";
|
|
68
|
+
import type { McpToolCallResult, UiActionRequest, UiResourceReader } from "@guuey/mcp-apps-host";
|
|
66
69
|
import { calmPolicy, debugPolicy, type TranscriptPolicy } from "../policy.js";
|
|
70
|
+
import { useStructuralIdentity } from "./structural-identity.js";
|
|
67
71
|
import { defaultChatStrings, type ChatStrings } from "../strings.js";
|
|
68
72
|
import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
|
|
69
|
-
import type {
|
|
73
|
+
import type {
|
|
74
|
+
ChatDebugEvent,
|
|
75
|
+
ErrorItem,
|
|
76
|
+
PlanViewSummary,
|
|
77
|
+
PromptItem,
|
|
78
|
+
UserMessageItem,
|
|
79
|
+
ViewRefItem,
|
|
80
|
+
} from "../types.js";
|
|
70
81
|
import type { ThemeMode } from "./theme-css.js";
|
|
71
82
|
import { Transcript, type TranscriptWindowing } from "./transcript.js";
|
|
72
|
-
import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
|
|
83
|
+
import type { TranscriptComponents, TranscriptItemContext, ViewSlotProps } from "./components.js";
|
|
73
84
|
import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
|
|
74
85
|
import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
|
|
75
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The kit-tier theme announce (guuey#302): default `hostContext.theme`
|
|
89
|
+
* from the transcript mode onto every view slot. A caller-declared
|
|
90
|
+
* `hostContext` key wins field-by-field; `theme` is only filled in.
|
|
91
|
+
* Exported for tests; not part of the package surface.
|
|
92
|
+
*/
|
|
93
|
+
export function viewPropsWithThemeAnnounce(
|
|
94
|
+
viewProps: TranscriptItemContext["viewProps"],
|
|
95
|
+
mode: ThemeMode,
|
|
96
|
+
defaults: Pick<ViewSlotProps, "onCallTool" | "onUpdateModelContext"> = {},
|
|
97
|
+
): TranscriptItemContext["viewProps"] {
|
|
98
|
+
const themed = (base: ViewSlotProps | undefined): ViewSlotProps => ({
|
|
99
|
+
// Kit-default host wires (guuey#335): the ACTION RELAY (Confirm inside
|
|
100
|
+
// a rendered card is a tools/call — without a relay the initialize-only
|
|
101
|
+
// host -32601s and the interaction visibly fails) and the
|
|
102
|
+
// model-context sink (a COMPLEMENT channel — producers mirror the
|
|
103
|
+
// snapshot server-side, so a recording sink is honest). A caller-
|
|
104
|
+
// declared slot prop always wins.
|
|
105
|
+
...defaults,
|
|
106
|
+
...base,
|
|
107
|
+
hostContext: { theme: mode, ...base?.hostContext },
|
|
108
|
+
});
|
|
109
|
+
if (typeof viewProps === "function") {
|
|
110
|
+
return (item, mount) => themed(viewProps(item, mount));
|
|
111
|
+
}
|
|
112
|
+
return themed(viewProps);
|
|
113
|
+
}
|
|
114
|
+
|
|
76
115
|
/**
|
|
77
116
|
* The imperative seam (guuey#210): programmatic send/prefill/focus for
|
|
78
117
|
* hosts that stay on the batteries-included path (suggested-prompt chips
|
|
@@ -107,6 +146,16 @@ export interface GuueyChatHandle {
|
|
|
107
146
|
* {@link GuueyChatProps.onThread}.
|
|
108
147
|
*/
|
|
109
148
|
readonly threadId: string | null;
|
|
149
|
+
/**
|
|
150
|
+
* The slot props the kit's OWN inline mounts run with — theme announce,
|
|
151
|
+
* default action relay, model-context sink (guuey#335). A host mounting
|
|
152
|
+
* a roster view on its own canvas (`<GuueyView {...handle.viewSlotProps()}`>)
|
|
153
|
+
* gets the identical wiring, so a rendered card's Confirm works there
|
|
154
|
+
* too. Live values (read on demand); when the host passed a FUNCTION-form
|
|
155
|
+
* `viewProps`, this returns the kit defaults (per-item resolution belongs
|
|
156
|
+
* to the transcript).
|
|
157
|
+
*/
|
|
158
|
+
viewSlotProps(): ViewSlotProps;
|
|
110
159
|
}
|
|
111
160
|
|
|
112
161
|
export interface GuueyChatProps {
|
|
@@ -208,6 +257,19 @@ export interface GuueyChatProps {
|
|
|
208
257
|
* {@link GuueyChatHandle.threadId} for the pull-style read.
|
|
209
258
|
*/
|
|
210
259
|
onThread?: (threadId: string) => void;
|
|
260
|
+
/**
|
|
261
|
+
* guuey#301's host-stage trio (all optional; absent = today's inline
|
|
262
|
+
* behavior). `promotedViewKey` = the mount key the host's stage/canvas
|
|
263
|
+
* currently shows (chips it in the transcript — with
|
|
264
|
+
* `policy.view.presentation: "chips"` EVERY view chips and this key
|
|
265
|
+
* marks the selected one). `onViewRef` fires on chip click — set the
|
|
266
|
+
* key from it for the browser-history mechanic. `onViewsChange`
|
|
267
|
+
* delivers the plan's view roster (key/title/phase/channel/mount) so
|
|
268
|
+
* the host can render the selected mount with `<GuueyView>`.
|
|
269
|
+
*/
|
|
270
|
+
promotedViewKey?: string;
|
|
271
|
+
onViewRef?: (item: ViewRefItem) => void;
|
|
272
|
+
onViewsChange?: (views: PlanViewSummary[]) => void;
|
|
211
273
|
className?: string;
|
|
212
274
|
style?: CSSProperties;
|
|
213
275
|
}
|
|
@@ -239,6 +301,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
239
301
|
reader,
|
|
240
302
|
onDebugEvent,
|
|
241
303
|
viewProps,
|
|
304
|
+
promotedViewKey,
|
|
305
|
+
onViewRef,
|
|
306
|
+
onViewsChange,
|
|
242
307
|
onPromptAction,
|
|
243
308
|
onHitlAnswer,
|
|
244
309
|
oauthReturnTo,
|
|
@@ -250,15 +315,37 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
250
315
|
style,
|
|
251
316
|
} = props;
|
|
252
317
|
|
|
318
|
+
// Identity stabilization (guuey#303 QA — the template's own chat-rail
|
|
319
|
+
// shipped the failure): hosts pass inline literals and arrows, so prop
|
|
320
|
+
// IDENTITY is noise. The getter props route through refs (presence, not
|
|
321
|
+
// identity, is the re-mint trigger — flipping guest↔bearer is a real
|
|
322
|
+
// change; a fresh arrow per render is not), and the policy/strings
|
|
323
|
+
// overrides stabilize structurally below. Without this, every host
|
|
324
|
+
// re-render re-minted the plan, whose views-emission effect calls the
|
|
325
|
+
// host back → setState → re-render → "Maximum update depth exceeded".
|
|
326
|
+
const getAccessTokenRef = useRef(getAccessToken);
|
|
327
|
+
getAccessTokenRef.current = getAccessToken;
|
|
328
|
+
const getGuestSecretRef = useRef(getGuestSecret);
|
|
329
|
+
getGuestSecretRef.current = getGuestSecret;
|
|
330
|
+
const hasAccessToken = getAccessToken !== undefined;
|
|
331
|
+
const hasGuestSecret = getGuestSecret !== undefined;
|
|
332
|
+
|
|
253
333
|
const adapters = useMemo(
|
|
254
334
|
() =>
|
|
255
335
|
adaptersProp ??
|
|
256
336
|
createWebAdapters({
|
|
257
337
|
...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}),
|
|
258
|
-
...(
|
|
259
|
-
|
|
338
|
+
...(hasAccessToken
|
|
339
|
+
? {
|
|
340
|
+
getAccessToken: (opts?: { forceRefresh?: boolean }) =>
|
|
341
|
+
getAccessTokenRef.current?.(opts) ?? Promise.resolve(null),
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
344
|
+
...(hasGuestSecret
|
|
345
|
+
? { getGuestSecret: () => getGuestSecretRef.current?.() ?? null }
|
|
346
|
+
: {}),
|
|
260
347
|
}),
|
|
261
|
-
[adaptersProp, apiBaseUrl,
|
|
348
|
+
[adaptersProp, apiBaseUrl, hasAccessToken, hasGuestSecret],
|
|
262
349
|
);
|
|
263
350
|
const invoke = useAgentInvoke({ endpointUrl, ...(appId !== undefined ? { appId } : {}), adapters, preserveBlocks: true });
|
|
264
351
|
|
|
@@ -282,39 +369,149 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
282
369
|
// the guest secret is re-resolved each call so a rotation takes
|
|
283
370
|
// effect immediately — the same per-request property
|
|
284
371
|
// `createWebAdapters` documents for its own resolvers.
|
|
372
|
+
// Getters read through the refs at CALL time — the reader's identity
|
|
373
|
+
// survives a host re-render handing in fresh arrows, and a rotated
|
|
374
|
+
// getter takes effect on the next read (the same per-request property
|
|
375
|
+
// `createWebAdapters` documents).
|
|
376
|
+
const getToken = getAccessTokenRef.current;
|
|
377
|
+
const getGuest = getGuestSecretRef.current;
|
|
285
378
|
const read = createUiResourceReader({
|
|
286
379
|
apiBaseUrl,
|
|
287
380
|
threadId,
|
|
288
381
|
endpointUrl,
|
|
289
|
-
...(
|
|
290
|
-
guestSecret:
|
|
382
|
+
...(getToken !== undefined ? { getAccessToken: getToken } : {}),
|
|
383
|
+
guestSecret: getGuest !== undefined ? getGuest() : null,
|
|
291
384
|
});
|
|
292
385
|
return read(resourceUri);
|
|
293
386
|
};
|
|
294
|
-
}, [apiBaseUrl, endpointUrl
|
|
387
|
+
}, [apiBaseUrl, endpointUrl]);
|
|
295
388
|
const effectiveReader = reader ?? defaultReader;
|
|
296
389
|
|
|
390
|
+
// Structurally-stable overrides: `policy={{ view: { … } }}` inline
|
|
391
|
+
// literals keep ONE identity while their contents hold still, so the
|
|
392
|
+
// policy (and through it the plan) does not re-mint per host render.
|
|
393
|
+
const stablePolicyOverrides = useStructuralIdentity(policyOverrides);
|
|
394
|
+
const stableStringOverrides = useStructuralIdentity(stringOverrides);
|
|
297
395
|
const policy = useMemo(() => {
|
|
298
396
|
const factory = preset === "debug" ? debugPolicy : calmPolicy;
|
|
299
397
|
const strings: ChatStrings = {
|
|
300
398
|
...defaultChatStrings,
|
|
301
|
-
...
|
|
302
|
-
...
|
|
399
|
+
...stablePolicyOverrides?.strings,
|
|
400
|
+
...stableStringOverrides,
|
|
303
401
|
};
|
|
304
|
-
return factory({ ...
|
|
305
|
-
}, [preset,
|
|
402
|
+
return factory({ ...stablePolicyOverrides, strings });
|
|
403
|
+
}, [preset, stablePolicyOverrides, stableStringOverrides]);
|
|
306
404
|
|
|
307
405
|
const { inputs, resolvePrompt, answerHitlPrompt } = useTranscriptInputs(invoke);
|
|
406
|
+
// Memoized: once a chip is selected (`promotedViewKey` set) this object
|
|
407
|
+
// is on the plan's identity path — a per-render fresh spread here was the
|
|
408
|
+
// second leg of the render loop the template surfaced.
|
|
409
|
+
const transcriptInputs = useMemo(
|
|
410
|
+
() => (promotedViewKey !== undefined ? { ...inputs, promotedViewKey } : inputs),
|
|
411
|
+
[inputs, promotedViewKey],
|
|
412
|
+
);
|
|
308
413
|
const { plan, toggle, resolvedMounts, onViewPhase, onViewDiagnosis } = useTranscript({
|
|
309
|
-
inputs,
|
|
414
|
+
inputs: transcriptInputs,
|
|
310
415
|
policy,
|
|
311
416
|
...(effectiveReader !== undefined ? { reader: effectiveReader } : {}),
|
|
312
417
|
...(onDebugEvent !== undefined ? { onDebugEvent } : {}),
|
|
313
418
|
});
|
|
314
419
|
|
|
420
|
+
// guuey#301: hand the host the plan's view roster whenever it changes —
|
|
421
|
+
// the stage renders the selected mount from it. Locator entries carry
|
|
422
|
+
// the RESOLUTION overlay (a stage mounts material, not identities):
|
|
423
|
+
// resolved material replaces the locator mount, a failed read surfaces
|
|
424
|
+
// as the expired phase. Ref'd callback so a host passing a fresh
|
|
425
|
+
// closure each render doesn't loop the effect.
|
|
426
|
+
const onViewsChangeRef = useRef(onViewsChange);
|
|
427
|
+
onViewsChangeRef.current = onViewsChange;
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (onViewsChangeRef.current === undefined) return;
|
|
430
|
+
const overlaid = plan.views.map((view) => {
|
|
431
|
+
const resolved = resolvedMounts.get(view.key);
|
|
432
|
+
if (resolved === undefined) return view;
|
|
433
|
+
if (resolved === "expired") return { ...view, phase: "expired" as const };
|
|
434
|
+
return { ...view, mount: resolved };
|
|
435
|
+
});
|
|
436
|
+
onViewsChangeRef.current(overlaid);
|
|
437
|
+
}, [plan.views, resolvedMounts]);
|
|
438
|
+
|
|
439
|
+
// The theme announce, completed at the kit tier (guuey#302): every
|
|
440
|
+
// inline mount carries `hostContext.theme` from the transcript's `mode`
|
|
441
|
+
// — render bundles read it at ui/initialize (ggui precedence: slice
|
|
442
|
+
// wins, host falls back — their #551/#573). A caller's explicit
|
|
443
|
+
// `viewProps.hostContext` keys win; `theme` is only defaulted. Hosts
|
|
444
|
+
// mounting views DIRECTLY (a canvas over the roster) announce on their
|
|
445
|
+
// own <GuueyView hostContext> — this covers the kit's own mounts.
|
|
446
|
+
// The DEFAULT ACTION RELAY (guuey#335 — the founder-hit Confirm bug):
|
|
447
|
+
// pod-then-persisted, built over the SAME identity as the reader, thread
|
|
448
|
+
// read through the ref at CALL time. Without it, a kit-mounted render's
|
|
449
|
+
// tools/call hits an initialize-only host and the card's interaction
|
|
450
|
+
// visibly fails — the #221 batteries-included treatment, applied to the
|
|
451
|
+
// ACTION half. No thread yet ⇒ the in-band unavailable result (a card
|
|
452
|
+
// cannot exist before its thread, but a race answers honestly).
|
|
453
|
+
const defaultOnCallTool = useMemo<((request: UiActionRequest) => Promise<McpToolCallResult>) | undefined>(() => {
|
|
454
|
+
if (apiBaseUrl === undefined) return undefined;
|
|
455
|
+
return async (request) => {
|
|
456
|
+
const threadId = threadIdRef.current;
|
|
457
|
+
if (threadId === null) return unavailableToolCallResult();
|
|
458
|
+
const getToken = getAccessTokenRef.current;
|
|
459
|
+
const getGuest = getGuestSecretRef.current;
|
|
460
|
+
const relay = createUiActionRelay({
|
|
461
|
+
apiBaseUrl,
|
|
462
|
+
threadId,
|
|
463
|
+
endpointUrl,
|
|
464
|
+
...(getToken !== undefined ? { getAccessToken: getToken } : {}),
|
|
465
|
+
guestSecret: getGuest !== undefined ? getGuest() : null,
|
|
466
|
+
});
|
|
467
|
+
return relay(request);
|
|
468
|
+
};
|
|
469
|
+
}, [apiBaseUrl, endpointUrl]);
|
|
470
|
+
|
|
471
|
+
// Model-context sink (guuey#335): the snapshot is a complement (producers
|
|
472
|
+
// mirror it server-side), so recording to the debug surface is the honest
|
|
473
|
+
// kit default — and answering the method keeps strict producers green.
|
|
474
|
+
const onDebugEventRef = useRef(onDebugEvent);
|
|
475
|
+
onDebugEventRef.current = onDebugEvent;
|
|
476
|
+
const defaultOnUpdateModelContext = useCallback((params: { [key: string]: unknown }) => {
|
|
477
|
+
onDebugEventRef.current?.({
|
|
478
|
+
type: "model-context-update",
|
|
479
|
+
byteSize: JSON.stringify(params)?.length ?? 0,
|
|
480
|
+
});
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
const effectiveViewProps = useMemo<TranscriptItemContext["viewProps"]>(
|
|
484
|
+
() =>
|
|
485
|
+
viewPropsWithThemeAnnounce(viewProps, mode, {
|
|
486
|
+
...(defaultOnCallTool !== undefined ? { onCallTool: defaultOnCallTool } : {}),
|
|
487
|
+
onUpdateModelContext: defaultOnUpdateModelContext,
|
|
488
|
+
}),
|
|
489
|
+
[viewProps, mode, defaultOnCallTool, defaultOnUpdateModelContext],
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// The canvas-host door (guuey#335): a host mounting views DIRECTLY from
|
|
493
|
+
// the roster (the chat-rail shell) needs the SAME slot wiring the kit's
|
|
494
|
+
// inline mounts get — theme announce, action relay, context sink. Read
|
|
495
|
+
// through a ref so the lifetime-stable handle always hands out current
|
|
496
|
+
// wiring; a caller-passed FUNCTION-form viewProps resolves per transcript
|
|
497
|
+
// item only, so the handle returns the kit defaults in that case.
|
|
498
|
+
const staticSlotPropsRef = useRef<ViewSlotProps>({});
|
|
499
|
+
staticSlotPropsRef.current =
|
|
500
|
+
typeof effectiveViewProps === "function"
|
|
501
|
+
? {
|
|
502
|
+
...(defaultOnCallTool !== undefined ? { onCallTool: defaultOnCallTool } : {}),
|
|
503
|
+
onUpdateModelContext: defaultOnUpdateModelContext,
|
|
504
|
+
hostContext: { theme: mode },
|
|
505
|
+
}
|
|
506
|
+
: (effectiveViewProps ?? {});
|
|
507
|
+
|
|
315
508
|
// ── Composer ─────────────────────────────────────────────────────────
|
|
316
509
|
const [input, setInput] = useState("");
|
|
317
510
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
511
|
+
// Browser form-field heuristics (a11y/autofill lints) flag a field with
|
|
512
|
+
// neither id nor name on every embedding site. useId keeps the id unique
|
|
513
|
+
// when several chats mount on one page — a static id would collide.
|
|
514
|
+
const composerId = useId();
|
|
318
515
|
const busy = invoke.status !== "ready";
|
|
319
516
|
const available = endpointUrl !== null;
|
|
320
517
|
const canSend = available && !busy && input.trim() !== "";
|
|
@@ -357,6 +554,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
357
554
|
get threadId(): string | null {
|
|
358
555
|
return threadIdRef.current;
|
|
359
556
|
},
|
|
557
|
+
// Same live-read discipline as `threadId`: the ref always holds the
|
|
558
|
+
// CURRENT kit wiring (guuey#335 — the canvas-host door).
|
|
559
|
+
viewSlotProps: (): ViewSlotProps => staticSlotPropsRef.current,
|
|
360
560
|
}),
|
|
361
561
|
[],
|
|
362
562
|
);
|
|
@@ -463,7 +663,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
463
663
|
resolvedMounts={resolvedMounts}
|
|
464
664
|
onViewPhase={onViewPhase}
|
|
465
665
|
onViewDiagnosis={onViewDiagnosis}
|
|
466
|
-
{...(
|
|
666
|
+
{...(onViewRef !== undefined ? { onViewRef } : {})}
|
|
667
|
+
viewProps={effectiveViewProps}
|
|
467
668
|
/>
|
|
468
669
|
{oauthReturn.notice !== null && (
|
|
469
670
|
<p
|
|
@@ -487,6 +688,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
487
688
|
>
|
|
488
689
|
<textarea
|
|
489
690
|
ref={inputRef}
|
|
691
|
+
id={composerId}
|
|
692
|
+
name="message"
|
|
490
693
|
className="guuey-chat-composer-input"
|
|
491
694
|
rows={1}
|
|
492
695
|
value={input}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity stabilization for host-supplied prop OBJECTS (guuey#303 QA).
|
|
3
|
+
*
|
|
4
|
+
* Hosts write `<GuueyChat policy={{ view: { presentation: "chips" } }}>` —
|
|
5
|
+
* a fresh object identity every render. Anything memo-keyed on that
|
|
6
|
+
* identity would re-mint per render, and anything the re-mint feeds into a
|
|
7
|
+
* `useEffect` → host `setState` edge becomes an infinite render loop (the
|
|
8
|
+
* template's chat-rail shipped exactly that). Identity is not a contract
|
|
9
|
+
* hosts signed up for; structure is.
|
|
10
|
+
*
|
|
11
|
+
* `useStructuralIdentity` returns the PREVIOUS reference while the new
|
|
12
|
+
* value is structurally equal, so downstream memos see one identity per
|
|
13
|
+
* structural value. Functions (and anything else non-plain) compare by
|
|
14
|
+
* reference — a policy override carrying an inline closure (for example
|
|
15
|
+
* `strings.humanizeTitle`) still churns; hoist such overrides.
|
|
16
|
+
*/
|
|
17
|
+
import { useRef } from "react";
|
|
18
|
+
|
|
19
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
if (typeof value !== "object" || value === null) return false;
|
|
21
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
22
|
+
return proto === Object.prototype || proto === null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function structurallyEqual(a: unknown, b: unknown): boolean {
|
|
26
|
+
if (Object.is(a, b)) return true;
|
|
27
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
28
|
+
return a.length === b.length && a.every((item, i) => structurallyEqual(item, b[i]));
|
|
29
|
+
}
|
|
30
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
31
|
+
const aKeys = Object.keys(a);
|
|
32
|
+
const bKeys = Object.keys(b);
|
|
33
|
+
return (
|
|
34
|
+
aKeys.length === bKeys.length &&
|
|
35
|
+
aKeys.every((key) => Object.hasOwn(b, key) && structurallyEqual(a[key], b[key]))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
// Functions, class instances, Maps, … — reference identity only (already
|
|
39
|
+
// handled by Object.is above).
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useStructuralIdentity<T>(value: T): T {
|
|
44
|
+
const ref = useRef(value);
|
|
45
|
+
if (!structurallyEqual(ref.current, value)) ref.current = value;
|
|
46
|
+
return ref.current;
|
|
47
|
+
}
|
|
@@ -174,12 +174,15 @@ export function useTranscript({
|
|
|
174
174
|
}, [plan]);
|
|
175
175
|
|
|
176
176
|
// Locator resolution — one read per locator key, misses become "expired".
|
|
177
|
+
// Walks the plan's VIEWS roster, not the display items: under the chips
|
|
178
|
+
// presentation (guuey#301) no inline mount renders, but the host's canvas
|
|
179
|
+
// still needs every locator resolved — the roster is presentation-
|
|
180
|
+
// independent, so resolution is too.
|
|
177
181
|
const readerRef = useRef(reader);
|
|
178
182
|
readerRef.current = reader;
|
|
179
183
|
const inFlight = useRef(new Set<ItemKey>());
|
|
180
184
|
useEffect(() => {
|
|
181
|
-
for (const item of plan.
|
|
182
|
-
if (item.kind !== "view") continue;
|
|
185
|
+
for (const item of plan.views) {
|
|
183
186
|
if (item.mount === null || item.mount.channel !== "locator") continue;
|
|
184
187
|
if (resolvedMounts.has(item.key) || inFlight.current.has(item.key)) continue;
|
|
185
188
|
const read = readerRef.current;
|
package/src/strings.ts
CHANGED
|
@@ -68,6 +68,10 @@ export interface ChatStrings {
|
|
|
68
68
|
}) => string;
|
|
69
69
|
/** guuey#204: the chip text for a mount promoted to a host stage/canvas. */
|
|
70
70
|
viewPromoted: (title: string) => string;
|
|
71
|
+
/** guuey#301 chips presentation: an unselected, mountable view's chip text. */
|
|
72
|
+
viewChip: (title: string) => string;
|
|
73
|
+
/** guuey#301 chips presentation: an expired/dead view's chip text (honest state). */
|
|
74
|
+
viewChipExpired: (title: string) => string;
|
|
71
75
|
/** Chip title when the mount has no producing-call title (history cards). */
|
|
72
76
|
viewRefFallbackTitle: string;
|
|
73
77
|
|
|
@@ -160,6 +164,8 @@ export const defaultChatStrings: ChatStrings = {
|
|
|
160
164
|
viewCspBlocked: (d) =>
|
|
161
165
|
`This page's Content-Security-Policy blocks ${d.blockedUri} — add "${d.violatedDirective} ${d.suggestedEntry}" to the policy so the view can start`,
|
|
162
166
|
viewPromoted: (title) => `${title} — on canvas`,
|
|
167
|
+
viewChip: (title) => title,
|
|
168
|
+
viewChipExpired: (title) => `${title} — expired`,
|
|
163
169
|
viewRefFallbackTitle: "Card",
|
|
164
170
|
|
|
165
171
|
recoveredFromHistory: "recovered from history",
|
package/src/types.ts
CHANGED
|
@@ -152,9 +152,11 @@ export interface TranscriptInputs {
|
|
|
152
152
|
* {@link ViewMountItem}, so an interactive surface exists exactly ONCE.
|
|
153
153
|
* Absent (or matching nothing / an expired mount) = today's behavior —
|
|
154
154
|
* every mount renders inline. Hosts derive the key with
|
|
155
|
-
* {@link newestViewKey} rather than hand-building it
|
|
156
|
-
*
|
|
157
|
-
*
|
|
155
|
+
* {@link newestViewKey} rather than hand-building it. Set per host
|
|
156
|
+
* click (via `onViewRef`) this is the SELECTION half of guuey#301's
|
|
157
|
+
* browser-history mechanic; the collapse-ALL-views half is the policy
|
|
158
|
+
* knob `view.presentation: "chips"` — the two compose, this field alone
|
|
159
|
+
* never chips more than the one promoted view.
|
|
158
160
|
*/
|
|
159
161
|
promotedViewKey?: string;
|
|
160
162
|
/** The last turn ended by user abort (R1 aborted-partial + "Stopped."). */
|
|
@@ -294,8 +296,23 @@ export interface ViewRefItem extends BaseItem {
|
|
|
294
296
|
kind: "viewRef";
|
|
295
297
|
/** The display title (the producing call's, or the strings fallback). */
|
|
296
298
|
title: string;
|
|
297
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* The full resolved chip text — `strings.viewPromoted(title)` for the
|
|
301
|
+
* selected chip, `strings.viewChip(title)` / `viewChipExpired(title)`
|
|
302
|
+
* for the rest under chips presentation (guuey#301).
|
|
303
|
+
*/
|
|
298
304
|
label: string;
|
|
305
|
+
/**
|
|
306
|
+
* True when this chip's mount is the one the host's stage currently
|
|
307
|
+
* shows (`key === promotedViewKey` and mountable). Renderers style it
|
|
308
|
+
* as the active history entry (guuey#301).
|
|
309
|
+
*/
|
|
310
|
+
selected: boolean;
|
|
311
|
+
/**
|
|
312
|
+
* The underlying mount's phase — chips presentation keeps expired /
|
|
313
|
+
* unresolved views honest instead of hiding their state (guuey#301).
|
|
314
|
+
*/
|
|
315
|
+
phase: ViewHostPhase | "expired";
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
/** R7 — media blocks. */
|
|
@@ -486,7 +503,40 @@ export type ChatDebugEvent =
|
|
|
486
503
|
diagnosis?: ViewCspDiagnosis;
|
|
487
504
|
}
|
|
488
505
|
| { type: "unknown-block"; key: ItemKey; typeName: string; byteSize: number }
|
|
489
|
-
| { type: "turn-recovered"; marker: string }
|
|
506
|
+
| { type: "turn-recovered"; marker: string }
|
|
507
|
+
/**
|
|
508
|
+
* A view pushed a `ui/update-model-context` snapshot and the kit's
|
|
509
|
+
* default sink recorded it (guuey#335). The snapshot itself is a
|
|
510
|
+
* COMPLEMENT — producers mirror it server-side — so the debug surface
|
|
511
|
+
* carries the fact and the size, not the payload.
|
|
512
|
+
*/
|
|
513
|
+
| { type: "model-context-update"; byteSize: number };
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* One renderable view the plan saw, BEFORE any chips/promotion pass —
|
|
517
|
+
* the host-canvas contract (guuey#301): everything a stage needs to
|
|
518
|
+
* render the selected mount and label a history rail, in transcript
|
|
519
|
+
* order (history cards first, live mounts after — the same order the
|
|
520
|
+
* items carry).
|
|
521
|
+
*/
|
|
522
|
+
export interface PlanViewSummary {
|
|
523
|
+
key: ItemKey;
|
|
524
|
+
/** The display title (the producing call's, or the strings fallback). */
|
|
525
|
+
title: string;
|
|
526
|
+
phase: ViewHostPhase | "expired";
|
|
527
|
+
channel: ViewMountChannel | null;
|
|
528
|
+
/** Null when the locator is dead (the R13 expired path). */
|
|
529
|
+
mount: ViewMount | null;
|
|
530
|
+
/** The persisted `ui://` locator actions bind to (guuey#158), or null. */
|
|
531
|
+
actionScope: string | null;
|
|
532
|
+
/**
|
|
533
|
+
* Provenance: a LIVE fold mount vs a persisted HISTORY card. The roster
|
|
534
|
+
* is in transcript order (history cards sit with the settled prefix), so
|
|
535
|
+
* "newest" recency needs this — live outranks history, exactly
|
|
536
|
+
* {@link newestViewKey}'s walk.
|
|
537
|
+
*/
|
|
538
|
+
origin: "live" | "history";
|
|
539
|
+
}
|
|
490
540
|
|
|
491
541
|
export interface TranscriptPlan {
|
|
492
542
|
/** Ordered, stable keys (spec §7's determinism contract). */
|
|
@@ -499,4 +549,10 @@ export interface TranscriptPlan {
|
|
|
499
549
|
* byte-identical to a streamed turn's (fixture 17).
|
|
500
550
|
*/
|
|
501
551
|
recovery: string | null;
|
|
552
|
+
/**
|
|
553
|
+
* Every view the plan saw (guuey#301's host-canvas contract) — present
|
|
554
|
+
* regardless of `view.presentation`, so a stage can render the selected
|
|
555
|
+
* mount even when the transcript shows only chips.
|
|
556
|
+
*/
|
|
557
|
+
views: PlanViewSummary[];
|
|
502
558
|
}
|
package/styles.css
CHANGED
|
@@ -262,6 +262,16 @@
|
|
|
262
262
|
color: var(--guuey-chat-ink);
|
|
263
263
|
border-color: color-mix(in srgb, var(--guuey-chat-ink) 24%, transparent);
|
|
264
264
|
}
|
|
265
|
+
/* guuey#301 chips presentation: the selected (on-stage) chip + honest expired state. */
|
|
266
|
+
.guuey-chat-view-ref-selected {
|
|
267
|
+
border-color: var(--guuey-chat-accent);
|
|
268
|
+
color: var(--guuey-chat-ink);
|
|
269
|
+
background: color-mix(in srgb, var(--guuey-chat-accent) 12%, var(--guuey-chat-surface));
|
|
270
|
+
}
|
|
271
|
+
.guuey-chat-view-ref-expired {
|
|
272
|
+
opacity: 0.6;
|
|
273
|
+
text-decoration: line-through;
|
|
274
|
+
}
|
|
265
275
|
|
|
266
276
|
/* ── R7 media ── */
|
|
267
277
|
.guuey-chat-media-image img {
|