@guuey/chat 0.5.0 → 0.6.1

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.
Files changed (61) hide show
  1. package/dist/hitl.d.ts +58 -0
  2. package/dist/hitl.d.ts.map +1 -0
  3. package/dist/hitl.js +81 -0
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -1
  7. package/dist/native/components.d.ts +18 -2
  8. package/dist/native/components.d.ts.map +1 -1
  9. package/dist/native/components.js +49 -0
  10. package/dist/native/transcript.d.ts +1 -1
  11. package/dist/native/transcript.d.ts.map +1 -1
  12. package/dist/native/transcript.js +2 -2
  13. package/dist/native.d.ts +1 -1
  14. package/dist/native.d.ts.map +1 -1
  15. package/dist/native.js +1 -1
  16. package/dist/plan.d.ts +15 -0
  17. package/dist/plan.d.ts.map +1 -1
  18. package/dist/plan.js +213 -15
  19. package/dist/policy.d.ts +4 -0
  20. package/dist/policy.d.ts.map +1 -1
  21. package/dist/policy.js +2 -0
  22. package/dist/react/components.d.ts +42 -5
  23. package/dist/react/components.d.ts.map +1 -1
  24. package/dist/react/components.js +51 -1
  25. package/dist/react/guuey-chat.d.ts +92 -7
  26. package/dist/react/guuey-chat.d.ts.map +1 -1
  27. package/dist/react/guuey-chat.js +114 -11
  28. package/dist/react/transcript.d.ts +1 -1
  29. package/dist/react/transcript.d.ts.map +1 -1
  30. package/dist/react/transcript.js +2 -2
  31. package/dist/react/use-transcript.d.ts +21 -2
  32. package/dist/react/use-transcript.d.ts.map +1 -1
  33. package/dist/react/use-transcript.js +57 -3
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.d.ts.map +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/strings.d.ts +13 -0
  38. package/dist/strings.d.ts.map +1 -1
  39. package/dist/strings.js +8 -0
  40. package/dist/types.d.ts +146 -7
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +5 -5
  43. package/src/corpus/README.md +17 -1
  44. package/src/corpus/__snapshots__/corpus.test.ts.snap +348 -0
  45. package/src/corpus/drive.ts +3 -3
  46. package/src/corpus/fixtures.ts +249 -1
  47. package/src/hitl.ts +103 -0
  48. package/src/index.ts +15 -0
  49. package/src/native/components.tsx +136 -1
  50. package/src/native/transcript.tsx +4 -3
  51. package/src/native.tsx +1 -0
  52. package/src/plan.ts +235 -22
  53. package/src/policy.ts +4 -0
  54. package/src/react/components.tsx +171 -8
  55. package/src/react/guuey-chat.tsx +222 -15
  56. package/src/react/transcript.tsx +4 -3
  57. package/src/react/use-transcript.ts +83 -5
  58. package/src/react.tsx +3 -1
  59. package/src/strings.ts +25 -0
  60. package/src/types.ts +152 -6
  61. package/styles.css +21 -0
@@ -31,29 +31,104 @@
31
31
  * adapter (e.g. `createWebAdapters({ apiBaseUrl, … })`) and a persisted
32
32
  * threadId rehydrates on mount — text transcript + persisted cards, which
33
33
  * mount through the same R6 path as live views. Nothing here to configure.
34
+ *
35
+ * ## The imperative seam (guuey#210)
36
+ *
37
+ * Suggested-prompt chips and other host-driven sends stay on the
38
+ * batteries-included path via {@link GuueyChatHandle} — a ref handle
39
+ * (`forwardRef`) and/or the `onReady` callback, ONE stable object for the
40
+ * component's whole life. `send` runs through exactly the Send button's
41
+ * gate (never bypasses it; the typed draft is left untouched — a chip send
42
+ * must not eat a half-typed message); `prefill` mirrors the widget's
43
+ * staged-composer semantics (append joins with a space, never clobbers).
44
+ * Web-only for now: the native tier ships `<NativeTranscript>` without a
45
+ * native GuueyChat, so there is no native surface to put a handle on yet.
34
46
  */
35
- import { useCallback, useMemo, useState, type CSSProperties, type ReactNode } from "react";
36
- import { createWebAdapters, type AgentInvokeAdapters } from "@guuey/agent-client";
47
+ import {
48
+ forwardRef,
49
+ useCallback,
50
+ useEffect,
51
+ useImperativeHandle,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ type CSSProperties,
56
+ type ReactNode,
57
+ } from "react";
58
+ import {
59
+ createUiResourceReader,
60
+ createWebAdapters,
61
+ type AgentInvokeAdapters,
62
+ } from "@guuey/agent-client";
63
+ import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
37
64
  import { useAgentInvoke } from "@guuey/agent-client/react";
38
65
  import type { UiResourceReader } from "@guuey/mcp-apps-host";
39
66
  import { calmPolicy, debugPolicy, type TranscriptPolicy } from "../policy.js";
40
67
  import { defaultChatStrings, type ChatStrings } from "../strings.js";
41
68
  import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
42
- import type { ErrorItem, PromptItem, UserMessageItem } from "../types.js";
69
+ import type { ChatDebugEvent, ErrorItem, PromptItem, UserMessageItem } from "../types.js";
43
70
  import type { ThemeMode } from "./theme-css.js";
44
71
  import { Transcript, type TranscriptWindowing } from "./transcript.js";
45
72
  import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
46
73
  import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
47
74
 
75
+ /**
76
+ * The imperative seam (guuey#210): programmatic send/prefill/focus for
77
+ * hosts that stay on the batteries-included path (suggested-prompt chips
78
+ * being the filing use case). Reach it via `ref` or `onReady` — both
79
+ * deliver the SAME stable object, valid for the component's whole life.
80
+ */
81
+ export interface GuueyChatHandle {
82
+ /**
83
+ * Send `text` through exactly the Send button's gate: returns `false`
84
+ * and does nothing when chat is unavailable (`endpointUrl === null`), a
85
+ * turn is in flight, or the text is blank — it NEVER bypasses the
86
+ * composer's rules. The typed draft is left untouched (a programmatic
87
+ * send must not eat a half-typed message). Failures surface the same
88
+ * way a composer send's do (the hook's `error` / R0 failed-send).
89
+ */
90
+ send(text: string): boolean;
91
+ /**
92
+ * Put `text` into the composer draft. `append: true` joins onto a
93
+ * non-empty draft with a single space (the widget's staged-composer
94
+ * semantic — never clobbers); default replaces. Focuses the input
95
+ * unless `focus: false`.
96
+ */
97
+ prefill(text: string, opts?: { focus?: boolean; append?: boolean }): void;
98
+ /** Focus the composer input. */
99
+ focusComposer(): void;
100
+ }
101
+
48
102
  export interface GuueyChatProps {
49
103
  /** Pod base URL (with or without `/agent/invoke`). `null` disables chat. */
50
104
  endpointUrl: string | null;
51
105
  /** Owning app id — namespaces the persisted threadId. */
52
106
  appId?: string;
107
+ /**
108
+ * The guuey public API base (`…/v1`). Enables the batteries-included
109
+ * read paths without hand-wiring: when set and no `adapters` are given,
110
+ * the default `createWebAdapters` gains transcript history; when set and
111
+ * no `reader` is given, a `UiResourceReader` is built over the same
112
+ * identity so generative-UI locators resolve (guuey#221 — a guest kit
113
+ * user has no bearer to construct one with). Explicit `adapters` /
114
+ * `reader` always win. Absent → today's behavior (no history, no
115
+ * default reader; locators render as expired, labeled).
116
+ */
117
+ apiBaseUrl?: string;
118
+ /**
119
+ * Identity for the default adapters + default reader — the same two
120
+ * resolvers `createWebAdapters` takes (bearer wins; guest secret next;
121
+ * neither → cookie identity). Ignored when explicit `adapters` and
122
+ * `reader` are both supplied.
123
+ */
124
+ getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
125
+ getGuestSecret?: () => string | null;
53
126
  /**
54
127
  * Host couplings (storage / id / transport / history). Default:
55
- * `createWebAdapters()` localStorage thread persistence + the web SSE
56
- * transport (cookie/guest identity, saturation + cold-start retries).
128
+ * `createWebAdapters({ apiBaseUrl, getAccessToken, getGuestSecret })`
129
+ * localStorage thread persistence + the web SSE transport (cookie/guest
130
+ * identity, saturation + cold-start retries), plus history when
131
+ * `apiBaseUrl` is set.
57
132
  */
58
133
  adapters?: AgentInvokeAdapters;
59
134
  /** Policy preset (spec §5). Default `"calm"`. */
@@ -68,8 +143,14 @@ export interface GuueyChatProps {
68
143
  mode?: ThemeMode;
69
144
  /** DOM windowing (§3.2). `false` renders everything. */
70
145
  window?: TranscriptWindowing | false;
71
- /** R6 locator resolution (history cards). See `useTranscript`. */
146
+ /**
147
+ * R6 locator resolution (history cards + meta-less live renders). See
148
+ * `useTranscript`. Defaults from `apiBaseUrl` (+ `endpointUrl` for the
149
+ * pod door) when omitted; an explicit reader always wins.
150
+ */
72
151
  reader?: UiResourceReader;
152
+ /** The debug sink (spec §5) — fires only under the debug policy. */
153
+ onDebugEvent?: (event: ChatDebugEvent) => void;
73
154
  /** R6 pass-through (relay hook, sandbox page/flags, host context…). */
74
155
  viewProps?: TranscriptItemContext["viewProps"];
75
156
  /**
@@ -77,9 +158,24 @@ export interface GuueyChatProps {
77
158
  * The transcript record moves regardless; without a handler the prompt
78
159
  * card is record-only.
79
160
  */
80
- onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
161
+ onPromptAction?: (
162
+ item: PromptItem,
163
+ action: "accept" | "decline" | "dismiss" | { grantModeId: string },
164
+ ) => void;
165
+ /**
166
+ * Receives the VALIDATED wire answer for an AgJSON HITL ask (spec
167
+ * draft.2) — the host owns delivering it (the kit has no answer
168
+ * transport). Fired after the transcript record moves.
169
+ */
170
+ onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
81
171
  /** R11 action slot (sign-in / retry affordances). */
82
172
  onErrorAction?: (item: ErrorItem) => void;
173
+ /**
174
+ * Callback route to the {@link GuueyChatHandle} for hosts that prefer
175
+ * wiring over refs. Fires ONCE per component instance, on mount, with
176
+ * the same stable handle the ref receives.
177
+ */
178
+ onReady?: (handle: GuueyChatHandle) => void;
83
179
  className?: string;
84
180
  style?: CSSProperties;
85
181
  }
@@ -90,10 +186,16 @@ const PROMPT_STATE = {
90
186
  dismiss: "dismissed",
91
187
  } as const;
92
188
 
93
- export function GuueyChat(props: GuueyChatProps): ReactNode {
189
+ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function GuueyChat(
190
+ props: GuueyChatProps,
191
+ ref,
192
+ ): ReactNode {
94
193
  const {
95
194
  endpointUrl,
96
195
  appId,
196
+ apiBaseUrl,
197
+ getAccessToken,
198
+ getGuestSecret,
97
199
  adapters: adaptersProp,
98
200
  preset = "calm",
99
201
  policy: policyOverrides,
@@ -103,16 +205,60 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
103
205
  mode = "light",
104
206
  window: windowing,
105
207
  reader,
208
+ onDebugEvent,
106
209
  viewProps,
107
210
  onPromptAction,
211
+ onHitlAnswer,
108
212
  onErrorAction,
213
+ onReady,
109
214
  className,
110
215
  style,
111
216
  } = props;
112
217
 
113
- const adapters = useMemo(() => adaptersProp ?? createWebAdapters(), [adaptersProp]);
218
+ const adapters = useMemo(
219
+ () =>
220
+ adaptersProp ??
221
+ createWebAdapters({
222
+ ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}),
223
+ ...(getAccessToken !== undefined ? { getAccessToken } : {}),
224
+ ...(getGuestSecret !== undefined ? { getGuestSecret } : {}),
225
+ }),
226
+ [adaptersProp, apiBaseUrl, getAccessToken, getGuestSecret],
227
+ );
114
228
  const invoke = useAgentInvoke({ endpointUrl, ...(appId !== undefined ? { appId } : {}), adapters, preserveBlocks: true });
115
229
 
230
+ // Default reader (guuey#221): built over the SAME identity as the
231
+ // transport/history, targeting the pod door (live turns) then the
232
+ // platform door (persisted). The threadId hydrates after mount and can
233
+ // change on reset, so the reader is a stable function that reads the
234
+ // CURRENT thread through a ref — `useTranscript` sees one reader for the
235
+ // component's life and never re-resolves every mount on a thread flip.
236
+ const threadIdRef = useRef<string | null>(invoke.threadId);
237
+ threadIdRef.current = invoke.threadId;
238
+ const defaultReader = useMemo<UiResourceReader | undefined>(() => {
239
+ if (apiBaseUrl === undefined) return undefined;
240
+ return async (resourceUri: string) => {
241
+ const threadId = threadIdRef.current;
242
+ // No thread yet ⇒ nothing persisted to scope a read to; a live-turn
243
+ // locator always arrives with the thread already admitted (the
244
+ // `session` frame precedes tool results).
245
+ if (threadId === null) return undefined;
246
+ // Assembled per read: `createUiResourceReader` is a cheap closure, and
247
+ // the guest secret is re-resolved each call so a rotation takes
248
+ // effect immediately — the same per-request property
249
+ // `createWebAdapters` documents for its own resolvers.
250
+ const read = createUiResourceReader({
251
+ apiBaseUrl,
252
+ threadId,
253
+ endpointUrl,
254
+ ...(getAccessToken !== undefined ? { getAccessToken } : {}),
255
+ guestSecret: getGuestSecret ? getGuestSecret() : null,
256
+ });
257
+ return read(resourceUri);
258
+ };
259
+ }, [apiBaseUrl, endpointUrl, getAccessToken, getGuestSecret]);
260
+ const effectiveReader = reader ?? defaultReader;
261
+
116
262
  const policy = useMemo(() => {
117
263
  const factory = preset === "debug" ? debugPolicy : calmPolicy;
118
264
  const strings: ChatStrings = {
@@ -123,19 +269,68 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
123
269
  return factory({ ...policyOverrides, strings });
124
270
  }, [preset, policyOverrides, stringOverrides]);
125
271
 
126
- const { inputs, resolvePrompt } = useTranscriptInputs(invoke);
272
+ const { inputs, resolvePrompt, answerHitlPrompt } = useTranscriptInputs(invoke);
127
273
  const { plan, toggle, resolvedMounts, onViewPhase } = useTranscript({
128
274
  inputs,
129
275
  policy,
130
- ...(reader !== undefined ? { reader } : {}),
276
+ ...(effectiveReader !== undefined ? { reader: effectiveReader } : {}),
277
+ ...(onDebugEvent !== undefined ? { onDebugEvent } : {}),
131
278
  });
132
279
 
133
280
  // ── Composer ─────────────────────────────────────────────────────────
134
281
  const [input, setInput] = useState("");
282
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
135
283
  const busy = invoke.status !== "ready";
136
284
  const available = endpointUrl !== null;
137
285
  const canSend = available && !busy && input.trim() !== "";
138
286
 
287
+ // ── The imperative seam (guuey#210) ──────────────────────────────────
288
+ // ONE stable handle for the component's whole life (hosts capture it in
289
+ // `onReady` and keep it), reading live truth through a ref so a call
290
+ // always sees the CURRENT gate — never a stale closure's.
291
+ const liveRef = useRef({ available, busy, invoke, onReady });
292
+ useEffect(() => {
293
+ liveRef.current = { available, busy, invoke, onReady };
294
+ });
295
+
296
+ const handle = useMemo<GuueyChatHandle>(
297
+ () => ({
298
+ send: (text: string): boolean => {
299
+ const live = liveRef.current;
300
+ const trimmed = text.trim();
301
+ // Exactly the Send button's gate — a handle send never bypasses it.
302
+ if (!live.available || live.busy || trimmed === "") return false;
303
+ void live.invoke.send(trimmed).catch(() => {
304
+ // Same contract as submit: the hook owns failure surfacing.
305
+ });
306
+ return true;
307
+ },
308
+ prefill: (text: string, opts?: { focus?: boolean; append?: boolean }): void => {
309
+ setInput((prev) =>
310
+ // The widget's staged-composer semantic: append joins with a
311
+ // space onto a non-empty draft, never clobbers it.
312
+ opts?.append === true && prev.trim() !== "" ? `${prev.trimEnd()} ${text}` : text,
313
+ );
314
+ if (opts?.focus !== false) inputRef.current?.focus();
315
+ },
316
+ focusComposer: (): void => {
317
+ inputRef.current?.focus();
318
+ },
319
+ }),
320
+ [],
321
+ );
322
+
323
+ useImperativeHandle(ref, () => handle, [handle]);
324
+
325
+ // `onReady` fires once per instance, on mount, with the stable handle
326
+ // (guarded ref: StrictMode's remount cycle must not double-fire it).
327
+ const readyFiredRef = useRef(false);
328
+ useEffect(() => {
329
+ if (readyFiredRef.current) return;
330
+ readyFiredRef.current = true;
331
+ liveRef.current.onReady?.(handle);
332
+ }, [handle]);
333
+
139
334
  const submit = useCallback((): void => {
140
335
  const text = input.trim();
141
336
  if (text === "" || !available || busy) return;
@@ -155,11 +350,22 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
155
350
  );
156
351
 
157
352
  const handlePromptAction = useCallback(
158
- (item: PromptItem, action: "accept" | "decline" | "dismiss") => {
159
- resolvePrompt(item.promptId, PROMPT_STATE[action]);
353
+ (item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
354
+ if (item.promptKind === "hitl") {
355
+ const answer = answerHitlPrompt(
356
+ item.ask,
357
+ typeof action === "object" ? action : action === "accept" ? "accept" : action,
358
+ );
359
+ onHitlAnswer?.(answer, item.ask);
360
+ onPromptAction?.(item, action);
361
+ return;
362
+ }
363
+ // Profile prompts only ever receive the string actions (the default
364
+ // card renders no mode buttons for them).
365
+ if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
160
366
  onPromptAction?.(item, action);
161
367
  },
162
- [resolvePrompt, onPromptAction],
368
+ [resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
163
369
  );
164
370
 
165
371
  const strings = policy.strings;
@@ -192,6 +398,7 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
192
398
  }}
193
399
  >
194
400
  <textarea
401
+ ref={inputRef}
195
402
  className="guuey-chat-composer-input"
196
403
  rows={1}
197
404
  value={input}
@@ -224,4 +431,4 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
224
431
  </form>
225
432
  </div>
226
433
  );
227
- }
434
+ });
@@ -46,7 +46,7 @@ export interface TranscriptWindowing {
46
46
  export interface TranscriptProps
47
47
  extends Pick<
48
48
  TranscriptItemContext,
49
- "onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "viewProps"
49
+ "onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "onViewRef" | "viewProps"
50
50
  > {
51
51
  plan: TranscriptPlan;
52
52
  /** Per-slot component overrides (spec §3's override column). */
@@ -77,6 +77,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
77
77
  onErrorAction,
78
78
  resolvedMounts,
79
79
  onViewPhase,
80
+ onViewRef,
80
81
  viewProps,
81
82
  } = props;
82
83
 
@@ -85,8 +86,8 @@ export function Transcript(props: TranscriptProps): ReactNode {
85
86
  [components],
86
87
  );
87
88
  const ctx: TranscriptItemContext = useMemo(
88
- () => ({ strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps }),
89
- [strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps],
89
+ () => ({ strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef, viewProps }),
90
+ [strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef, viewProps],
90
91
  );
91
92
 
92
93
  // ── Windowing ────────────────────────────────────────────────────────
@@ -13,17 +13,20 @@
13
13
  */
14
14
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
15
  import type { UseAgentInvokeReturn } from "@guuey/agent-client";
16
+ import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
16
17
  import {
17
18
  resolveViewMount,
18
19
  type ResolvedViewMount,
19
20
  type UiResourceReader,
20
21
  type ViewHostPhase,
21
22
  } from "@guuey/mcp-apps-host";
23
+ import { buildHitlAnswer, hitlPromptsFromFold, type HitlAnswerRecord, type HitlPromptAction } from "../hitl.js";
22
24
  import { planTranscript } from "../plan.js";
23
25
  import type { TranscriptPolicy } from "../policy.js";
24
26
  import type {
27
+ ChatDebugEvent,
25
28
  ItemKey,
26
- PromptItemInput,
29
+ ProfilePromptInput,
27
30
  TranscriptInputs,
28
31
  TranscriptMessage,
29
32
  TranscriptOverrides,
@@ -42,6 +45,13 @@ export interface UseTranscriptArgs {
42
45
  * resolution — labeled, never blank.
43
46
  */
44
47
  reader?: UiResourceReader;
48
+ /**
49
+ * The debug sink (spec §5, real API since the #135 refinement wave):
50
+ * receives {@link ChatDebugEvent}s — view-phase transitions, R15
51
+ * unknown-block sightings, the #192 recovered-turn marker. Fires ONLY
52
+ * under the debug policy (`debugDetail`); `calm` ignores it by design.
53
+ */
54
+ onDebugEvent?: (event: ChatDebugEvent) => void;
45
55
  }
46
56
 
47
57
  export interface UseTranscriptResult {
@@ -56,13 +66,22 @@ export interface UseTranscriptResult {
56
66
  resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
57
67
  }
58
68
 
59
- export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): UseTranscriptResult {
69
+ export function useTranscript({
70
+ inputs,
71
+ policy,
72
+ reader,
73
+ onDebugEvent,
74
+ }: UseTranscriptArgs): UseTranscriptResult {
60
75
  const [overrides, setOverrides] = useState<TranscriptOverrides>({});
61
76
  const [phases, setPhases] = useState<Readonly<Record<string, ViewHostPhase>>>({});
62
77
  const [resolvedMounts, setResolvedMounts] = useState<
63
78
  ReadonlyMap<ItemKey, ResolvedViewMount | "expired">
64
79
  >(new Map());
65
80
 
81
+ // The debug sink (gated on the debug policy — calm ignores it, spec §5).
82
+ const debugSink = useRef<((event: ChatDebugEvent) => void) | null>(null);
83
+ debugSink.current = policy.debugDetail && onDebugEvent !== undefined ? onDebugEvent : null;
84
+
66
85
  const merged = useMemo<TranscriptInputs>(
67
86
  () => ({ ...inputs, viewPhases: { ...inputs.viewPhases, ...phases } }),
68
87
  [inputs, phases],
@@ -92,10 +111,37 @@ export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): Us
92
111
  setOverrides((prev) => ({ ...prev, [key]: { expanded: !current } }));
93
112
  }, []);
94
113
 
114
+ // Mirror of `phases` for the change check OUTSIDE the state updater — a
115
+ // sink call inside an updater would double-fire under StrictMode.
116
+ const phasesRef = useRef<Readonly<Record<string, ViewHostPhase>>>({});
95
117
  const onViewPhase = useCallback((key: ItemKey, phase: ViewHostPhase) => {
118
+ if (phasesRef.current[key] !== phase) {
119
+ phasesRef.current = { ...phasesRef.current, [key]: phase };
120
+ debugSink.current?.({ type: "view-phase", key, phase });
121
+ }
96
122
  setPhases((prev) => (prev[key] === phase ? prev : { ...prev, [key]: phase }));
97
123
  }, []);
98
124
 
125
+ // Plan-derived debug events, emitted once per sighting (post-render — the
126
+ // plan itself stays pure data).
127
+ const emittedUnknowns = useRef(new Set<ItemKey>());
128
+ const recoveryEmitted = useRef(false);
129
+ useEffect(() => {
130
+ const sink = debugSink.current;
131
+ if (sink === null) return;
132
+ for (const item of plan.items) {
133
+ if (item.kind !== "unknown" || emittedUnknowns.current.has(item.key)) continue;
134
+ emittedUnknowns.current.add(item.key);
135
+ sink({ type: "unknown-block", key: item.key, typeName: item.typeName, byteSize: item.byteSize });
136
+ }
137
+ if (plan.recovery !== null && !recoveryEmitted.current) {
138
+ recoveryEmitted.current = true;
139
+ sink({ type: "turn-recovered", marker: plan.recovery });
140
+ } else if (plan.recovery === null) {
141
+ recoveryEmitted.current = false;
142
+ }
143
+ }, [plan]);
144
+
99
145
  // Locator resolution — one read per locator key, misses become "expired".
100
146
  const readerRef = useRef(reader);
101
147
  readerRef.current = reader;
@@ -110,6 +156,10 @@ export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): Us
110
156
  inFlight.current.add(item.key);
111
157
  const settle = (value: ResolvedViewMount | "expired"): void => {
112
158
  inFlight.current.delete(item.key);
159
+ // The R13 expired verdict is a phase transition too (debug sink).
160
+ if (value === "expired") {
161
+ debugSink.current?.({ type: "view-phase", key: item.key, phase: "expired" });
162
+ }
113
163
  setResolvedMounts((prev) => {
114
164
  const next = new Map(prev);
115
165
  next.set(item.key, value);
@@ -141,6 +191,16 @@ export interface UseTranscriptInputsResult {
141
191
  * without a recorded action reads as `dismissed`.
142
192
  */
143
193
  resolvePrompt: (id: string, state: "answered" | "declined" | "dismissed") => void;
194
+ /**
195
+ * Answer an AgJSON HITL ask (spec draft.2): constructs the wire answer,
196
+ * VALIDATES it against the ask's persisted record (`validateHitlAnswer`
197
+ * — required-iff-declared, echo-must-be-declared, requestState byte-echo)
198
+ * BEFORE anything dispatches, records it in the ledger, and returns it
199
+ * for the HOST to deliver — the kit renders and validates; the answer
200
+ * transport is the host's (no client→pod hitl-answer channel exists on
201
+ * the guuey wire today; see the #16 producer flag).
202
+ */
203
+ answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
144
204
  }
145
205
 
146
206
  /** How often the escalation clock ticks while a status needs one. */
@@ -161,7 +221,7 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
161
221
 
162
222
  // R10 ledger: the hook exposes only the LATEST pending ask; the
163
223
  // transcript keeps the record of every ask and its resolution.
164
- const [prompts, setPrompts] = useState<PromptItemInput[]>([]);
224
+ const [prompts, setPrompts] = useState<ProfilePromptInput[]>([]);
165
225
  const promptSeq = useRef(0);
166
226
  useEffect(() => {
167
227
  const request = invoke.profileConsentRequest;
@@ -228,6 +288,23 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
228
288
  [invoke, prompts],
229
289
  );
230
290
 
291
+ // HITL ledger (spec draft.2): asks come from the FOLD's persisted
292
+ // records; this ledger holds only the host-side answers, keyed by askId.
293
+ // A `cancelled` record keeps the card re-askable (guuey's #16 ruling) —
294
+ // a later action on the same ask simply overwrites it.
295
+ const [hitlAnswers, setHitlAnswers] = useState<Readonly<Record<string, HitlAnswerRecord>>>({});
296
+ const answerHitlPrompt = useCallback((ask: AgPausedAsk, action: HitlPromptAction): AgHitlAnswer => {
297
+ const answer = buildHitlAnswer(ask, action);
298
+ setHitlAnswers((prev) => ({
299
+ ...prev,
300
+ [ask.askId]: {
301
+ status: answer.status,
302
+ ...(answer.grantModeId !== undefined ? { grantModeId: answer.grantModeId } : {}),
303
+ },
304
+ }));
305
+ return answer;
306
+ }, []);
307
+
231
308
  const inputs = useMemo<TranscriptInputs>(() => {
232
309
  // Source-ownership split (plan.ts's rules): the trailing assistant
233
310
  // entry is the IN-FLIGHT fold (or the abort-kept partial) — it moves to
@@ -248,7 +325,7 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
248
325
  statusElapsedMs: elapsedMs,
249
326
  activeTool: invoke.activeTool,
250
327
  error: invoke.error !== null ? { message: invoke.error, code: invoke.errorCode } : null,
251
- prompts,
328
+ prompts: [...prompts, ...hitlPromptsFromFold(invoke.reduceResult, hitlAnswers)],
252
329
  messages,
253
330
  ...(invoke.historyCards.length > 0 ? { historyCards: invoke.historyCards } : {}),
254
331
  sendStates: invoke.sendStates,
@@ -268,7 +345,8 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
268
345
  invoke.adopted,
269
346
  elapsedMs,
270
347
  prompts,
348
+ hitlAnswers,
271
349
  ]);
272
350
 
273
- return { inputs, resolvePrompt };
351
+ return { inputs, resolvePrompt, answerHitlPrompt };
274
352
  }
package/src/react.tsx CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  DefaultToolGroup,
28
28
  DefaultDataResult,
29
29
  DefaultView,
30
+ DefaultViewRef,
30
31
  DefaultMedia,
31
32
  DefaultCode,
32
33
  DefaultCitations,
@@ -38,6 +39,7 @@ export {
38
39
  DefaultStatus,
39
40
  type TranscriptComponents,
40
41
  type TranscriptItemContext,
42
+ type ViewSlotProps,
41
43
  } from "./react/components.js";
42
44
  export {
43
45
  useTranscript,
@@ -46,6 +48,6 @@ export {
46
48
  type UseTranscriptResult,
47
49
  type UseTranscriptInputsResult,
48
50
  } from "./react/use-transcript.js";
49
- export { GuueyChat, type GuueyChatProps } from "./react/guuey-chat.js";
51
+ export { GuueyChat, type GuueyChatProps, type GuueyChatHandle } from "./react/guuey-chat.js";
50
52
  export { Markdown } from "./react/markdown.js";
51
53
  export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
package/src/strings.ts CHANGED
@@ -55,6 +55,10 @@ export interface ChatStrings {
55
55
  viewInlineFallback: string;
56
56
  viewExpired: string;
57
57
  viewSandboxUnavailable: string;
58
+ /** guuey#204: the chip text for a mount promoted to a host stage/canvas. */
59
+ viewPromoted: (title: string) => string;
60
+ /** Chip title when the mount has no producing-call title (history cards). */
61
+ viewRefFallbackTitle: string;
58
62
 
59
63
  /** #192 debug-preset marker (calm never shows it — spec §3, F10). */
60
64
  recoveredFromHistory: string;
@@ -74,6 +78,17 @@ export interface ChatStrings {
74
78
  copy: string;
75
79
  copied: string;
76
80
 
81
+ /** R10 hitl actions (spec draft.2) — mode buttons use the ASKER's labels. */
82
+ promptAccept: string;
83
+ promptDecline: string;
84
+ promptDismissed: string;
85
+ /** The answered record line, e.g. `Allowed — Always`. */
86
+ promptAnsweredWith: (modeLabel: string) => string;
87
+ promptDeclinedRecord: string;
88
+
89
+ /** R16 — the notice row's label (provenance shows only under debug). */
90
+ noticeLabel: string;
91
+
77
92
  /** The 3c composer (`<GuueyChat>`). */
78
93
  composerPlaceholder: string;
79
94
  composerUnavailable: string;
@@ -121,6 +136,8 @@ export const defaultChatStrings: ChatStrings = {
121
136
  viewInlineFallback: "Showing plain content",
122
137
  viewExpired: "This view expired",
123
138
  viewSandboxUnavailable: "Interactive view unavailable",
139
+ viewPromoted: (title) => `${title} — on canvas`,
140
+ viewRefFallbackTitle: "Card",
124
141
 
125
142
  recoveredFromHistory: "recovered from history",
126
143
 
@@ -136,6 +153,14 @@ export const defaultChatStrings: ChatStrings = {
136
153
  copy: "Copy",
137
154
  copied: "Copied",
138
155
 
156
+ promptAccept: "Allow",
157
+ promptDecline: "Don't allow",
158
+ promptDismissed: "Dismissed",
159
+ promptAnsweredWith: (modeLabel) => `Allowed — ${modeLabel}`,
160
+ promptDeclinedRecord: "Not allowed",
161
+
162
+ noticeLabel: "Note",
163
+
139
164
  composerPlaceholder: "Message the agent…",
140
165
  composerUnavailable: "Chat is unavailable.",
141
166
  composerLabel: "Message",