@guuey/chat 0.9.0 → 0.11.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.
@@ -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,
@@ -64,9 +65,17 @@ import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
64
65
  import { useAgentInvoke } from "@guuey/agent-client/react";
65
66
  import type { UiResourceReader } from "@guuey/mcp-apps-host";
66
67
  import { calmPolicy, debugPolicy, type TranscriptPolicy } from "../policy.js";
68
+ import { useStructuralIdentity } from "./structural-identity.js";
67
69
  import { defaultChatStrings, type ChatStrings } from "../strings.js";
68
70
  import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
69
- import type { ChatDebugEvent, ErrorItem, PromptItem, UserMessageItem } from "../types.js";
71
+ import type {
72
+ ChatDebugEvent,
73
+ ErrorItem,
74
+ PlanViewSummary,
75
+ PromptItem,
76
+ UserMessageItem,
77
+ ViewRefItem,
78
+ } from "../types.js";
70
79
  import type { ThemeMode } from "./theme-css.js";
71
80
  import { Transcript, type TranscriptWindowing } from "./transcript.js";
72
81
  import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
@@ -208,6 +217,19 @@ export interface GuueyChatProps {
208
217
  * {@link GuueyChatHandle.threadId} for the pull-style read.
209
218
  */
210
219
  onThread?: (threadId: string) => void;
220
+ /**
221
+ * guuey#301's host-stage trio (all optional; absent = today's inline
222
+ * behavior). `promotedViewKey` = the mount key the host's stage/canvas
223
+ * currently shows (chips it in the transcript — with
224
+ * `policy.view.presentation: "chips"` EVERY view chips and this key
225
+ * marks the selected one). `onViewRef` fires on chip click — set the
226
+ * key from it for the browser-history mechanic. `onViewsChange`
227
+ * delivers the plan's view roster (key/title/phase/channel/mount) so
228
+ * the host can render the selected mount with `<GuueyView>`.
229
+ */
230
+ promotedViewKey?: string;
231
+ onViewRef?: (item: ViewRefItem) => void;
232
+ onViewsChange?: (views: PlanViewSummary[]) => void;
211
233
  className?: string;
212
234
  style?: CSSProperties;
213
235
  }
@@ -239,6 +261,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
239
261
  reader,
240
262
  onDebugEvent,
241
263
  viewProps,
264
+ promotedViewKey,
265
+ onViewRef,
266
+ onViewsChange,
242
267
  onPromptAction,
243
268
  onHitlAnswer,
244
269
  oauthReturnTo,
@@ -250,15 +275,37 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
250
275
  style,
251
276
  } = props;
252
277
 
278
+ // Identity stabilization (guuey#303 QA — the template's own chat-rail
279
+ // shipped the failure): hosts pass inline literals and arrows, so prop
280
+ // IDENTITY is noise. The getter props route through refs (presence, not
281
+ // identity, is the re-mint trigger — flipping guest↔bearer is a real
282
+ // change; a fresh arrow per render is not), and the policy/strings
283
+ // overrides stabilize structurally below. Without this, every host
284
+ // re-render re-minted the plan, whose views-emission effect calls the
285
+ // host back → setState → re-render → "Maximum update depth exceeded".
286
+ const getAccessTokenRef = useRef(getAccessToken);
287
+ getAccessTokenRef.current = getAccessToken;
288
+ const getGuestSecretRef = useRef(getGuestSecret);
289
+ getGuestSecretRef.current = getGuestSecret;
290
+ const hasAccessToken = getAccessToken !== undefined;
291
+ const hasGuestSecret = getGuestSecret !== undefined;
292
+
253
293
  const adapters = useMemo(
254
294
  () =>
255
295
  adaptersProp ??
256
296
  createWebAdapters({
257
297
  ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}),
258
- ...(getAccessToken !== undefined ? { getAccessToken } : {}),
259
- ...(getGuestSecret !== undefined ? { getGuestSecret } : {}),
298
+ ...(hasAccessToken
299
+ ? {
300
+ getAccessToken: (opts?: { forceRefresh?: boolean }) =>
301
+ getAccessTokenRef.current?.(opts) ?? Promise.resolve(null),
302
+ }
303
+ : {}),
304
+ ...(hasGuestSecret
305
+ ? { getGuestSecret: () => getGuestSecretRef.current?.() ?? null }
306
+ : {}),
260
307
  }),
261
- [adaptersProp, apiBaseUrl, getAccessToken, getGuestSecret],
308
+ [adaptersProp, apiBaseUrl, hasAccessToken, hasGuestSecret],
262
309
  );
263
310
  const invoke = useAgentInvoke({ endpointUrl, ...(appId !== undefined ? { appId } : {}), adapters, preserveBlocks: true });
264
311
 
@@ -282,39 +329,80 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
282
329
  // the guest secret is re-resolved each call so a rotation takes
283
330
  // effect immediately — the same per-request property
284
331
  // `createWebAdapters` documents for its own resolvers.
332
+ // Getters read through the refs at CALL time — the reader's identity
333
+ // survives a host re-render handing in fresh arrows, and a rotated
334
+ // getter takes effect on the next read (the same per-request property
335
+ // `createWebAdapters` documents).
336
+ const getToken = getAccessTokenRef.current;
337
+ const getGuest = getGuestSecretRef.current;
285
338
  const read = createUiResourceReader({
286
339
  apiBaseUrl,
287
340
  threadId,
288
341
  endpointUrl,
289
- ...(getAccessToken !== undefined ? { getAccessToken } : {}),
290
- guestSecret: getGuestSecret ? getGuestSecret() : null,
342
+ ...(getToken !== undefined ? { getAccessToken: getToken } : {}),
343
+ guestSecret: getGuest !== undefined ? getGuest() : null,
291
344
  });
292
345
  return read(resourceUri);
293
346
  };
294
- }, [apiBaseUrl, endpointUrl, getAccessToken, getGuestSecret]);
347
+ }, [apiBaseUrl, endpointUrl]);
295
348
  const effectiveReader = reader ?? defaultReader;
296
349
 
350
+ // Structurally-stable overrides: `policy={{ view: { … } }}` inline
351
+ // literals keep ONE identity while their contents hold still, so the
352
+ // policy (and through it the plan) does not re-mint per host render.
353
+ const stablePolicyOverrides = useStructuralIdentity(policyOverrides);
354
+ const stableStringOverrides = useStructuralIdentity(stringOverrides);
297
355
  const policy = useMemo(() => {
298
356
  const factory = preset === "debug" ? debugPolicy : calmPolicy;
299
357
  const strings: ChatStrings = {
300
358
  ...defaultChatStrings,
301
- ...policyOverrides?.strings,
302
- ...stringOverrides,
359
+ ...stablePolicyOverrides?.strings,
360
+ ...stableStringOverrides,
303
361
  };
304
- return factory({ ...policyOverrides, strings });
305
- }, [preset, policyOverrides, stringOverrides]);
362
+ return factory({ ...stablePolicyOverrides, strings });
363
+ }, [preset, stablePolicyOverrides, stableStringOverrides]);
306
364
 
307
365
  const { inputs, resolvePrompt, answerHitlPrompt } = useTranscriptInputs(invoke);
366
+ // Memoized: once a chip is selected (`promotedViewKey` set) this object
367
+ // is on the plan's identity path — a per-render fresh spread here was the
368
+ // second leg of the render loop the template surfaced.
369
+ const transcriptInputs = useMemo(
370
+ () => (promotedViewKey !== undefined ? { ...inputs, promotedViewKey } : inputs),
371
+ [inputs, promotedViewKey],
372
+ );
308
373
  const { plan, toggle, resolvedMounts, onViewPhase, onViewDiagnosis } = useTranscript({
309
- inputs,
374
+ inputs: transcriptInputs,
310
375
  policy,
311
376
  ...(effectiveReader !== undefined ? { reader: effectiveReader } : {}),
312
377
  ...(onDebugEvent !== undefined ? { onDebugEvent } : {}),
313
378
  });
314
379
 
380
+ // guuey#301: hand the host the plan's view roster whenever it changes —
381
+ // the stage renders the selected mount from it. Locator entries carry
382
+ // the RESOLUTION overlay (a stage mounts material, not identities):
383
+ // resolved material replaces the locator mount, a failed read surfaces
384
+ // as the expired phase. Ref'd callback so a host passing a fresh
385
+ // closure each render doesn't loop the effect.
386
+ const onViewsChangeRef = useRef(onViewsChange);
387
+ onViewsChangeRef.current = onViewsChange;
388
+ useEffect(() => {
389
+ if (onViewsChangeRef.current === undefined) return;
390
+ const overlaid = plan.views.map((view) => {
391
+ const resolved = resolvedMounts.get(view.key);
392
+ if (resolved === undefined) return view;
393
+ if (resolved === "expired") return { ...view, phase: "expired" as const };
394
+ return { ...view, mount: resolved };
395
+ });
396
+ onViewsChangeRef.current(overlaid);
397
+ }, [plan.views, resolvedMounts]);
398
+
315
399
  // ── Composer ─────────────────────────────────────────────────────────
316
400
  const [input, setInput] = useState("");
317
401
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
402
+ // Browser form-field heuristics (a11y/autofill lints) flag a field with
403
+ // neither id nor name on every embedding site. useId keeps the id unique
404
+ // when several chats mount on one page — a static id would collide.
405
+ const composerId = useId();
318
406
  const busy = invoke.status !== "ready";
319
407
  const available = endpointUrl !== null;
320
408
  const canSend = available && !busy && input.trim() !== "";
@@ -463,6 +551,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
463
551
  resolvedMounts={resolvedMounts}
464
552
  onViewPhase={onViewPhase}
465
553
  onViewDiagnosis={onViewDiagnosis}
554
+ {...(onViewRef !== undefined ? { onViewRef } : {})}
466
555
  {...(viewProps !== undefined ? { viewProps } : {})}
467
556
  />
468
557
  {oauthReturn.notice !== null && (
@@ -487,6 +576,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
487
576
  >
488
577
  <textarea
489
578
  ref={inputRef}
579
+ id={composerId}
580
+ name="message"
490
581
  className="guuey-chat-composer-input"
491
582
  rows={1}
492
583
  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.items) {
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; the issue's
156
- * phase-2 (every card a chip, click-to-swap) is this same field set per
157
- * host click, no further plan changes.
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
- /** The full resolved chip text (`strings.viewPromoted(title)`). */
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. */
@@ -488,6 +505,32 @@ export type ChatDebugEvent =
488
505
  | { type: "unknown-block"; key: ItemKey; typeName: string; byteSize: number }
489
506
  | { type: "turn-recovered"; marker: string };
490
507
 
508
+ /**
509
+ * One renderable view the plan saw, BEFORE any chips/promotion pass —
510
+ * the host-canvas contract (guuey#301): everything a stage needs to
511
+ * render the selected mount and label a history rail, in transcript
512
+ * order (history cards first, live mounts after — the same order the
513
+ * items carry).
514
+ */
515
+ export interface PlanViewSummary {
516
+ key: ItemKey;
517
+ /** The display title (the producing call's, or the strings fallback). */
518
+ title: string;
519
+ phase: ViewHostPhase | "expired";
520
+ channel: ViewMountChannel | null;
521
+ /** Null when the locator is dead (the R13 expired path). */
522
+ mount: ViewMount | null;
523
+ /** The persisted `ui://` locator actions bind to (guuey#158), or null. */
524
+ actionScope: string | null;
525
+ /**
526
+ * Provenance: a LIVE fold mount vs a persisted HISTORY card. The roster
527
+ * is in transcript order (history cards sit with the settled prefix), so
528
+ * "newest" recency needs this — live outranks history, exactly
529
+ * {@link newestViewKey}'s walk.
530
+ */
531
+ origin: "live" | "history";
532
+ }
533
+
491
534
  export interface TranscriptPlan {
492
535
  /** Ordered, stable keys (spec §7's determinism contract). */
493
536
  items: DisplayItem[];
@@ -499,4 +542,10 @@ export interface TranscriptPlan {
499
542
  * byte-identical to a streamed turn's (fixture 17).
500
543
  */
501
544
  recovery: string | null;
545
+ /**
546
+ * Every view the plan saw (guuey#301's host-canvas contract) — present
547
+ * regardless of `view.presentation`, so a stage can render the selected
548
+ * mount even when the transcript shows only chips.
549
+ */
550
+ views: PlanViewSummary[];
502
551
  }
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 {