@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.
@@ -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="guuey-chat-view-ref" role="note">
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="guuey-chat-view-ref guuey-chat-view-ref-button"
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}
@@ -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 type { UiResourceReader } from "@guuey/mcp-apps-host";
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 { ChatDebugEvent, ErrorItem, PromptItem, UserMessageItem } from "../types.js";
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
- ...(getAccessToken !== undefined ? { getAccessToken } : {}),
259
- ...(getGuestSecret !== undefined ? { getGuestSecret } : {}),
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, getAccessToken, getGuestSecret],
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
- ...(getAccessToken !== undefined ? { getAccessToken } : {}),
290
- guestSecret: getGuestSecret ? getGuestSecret() : null,
382
+ ...(getToken !== undefined ? { getAccessToken: getToken } : {}),
383
+ guestSecret: getGuest !== undefined ? getGuest() : null,
291
384
  });
292
385
  return read(resourceUri);
293
386
  };
294
- }, [apiBaseUrl, endpointUrl, getAccessToken, getGuestSecret]);
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
- ...policyOverrides?.strings,
302
- ...stringOverrides,
399
+ ...stablePolicyOverrides?.strings,
400
+ ...stableStringOverrides,
303
401
  };
304
- return factory({ ...policyOverrides, strings });
305
- }, [preset, policyOverrides, stringOverrides]);
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
- {...(viewProps !== undefined ? { viewProps } : {})}
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.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. */
@@ -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 {