@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
package/src/native.tsx CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  NativeToolGroup,
34
34
  NativeDataResult,
35
35
  NativeView,
36
+ NativeViewRef,
36
37
  NativeMedia,
37
38
  NativeCode,
38
39
  NativeCitations,
package/src/plan.ts CHANGED
@@ -9,11 +9,14 @@
9
9
  * Source-ownership rules (spec §9 Change-1 — one unified plan owns both):
10
10
  *
11
11
  * - USER rows always come from the flat `inputs.messages`.
12
- * - ASSISTANT content comes from the fold (`inputs.result`) when present;
13
- * otherwise from the flat assistant entries plus the in-flight
14
- * `assistantText`. Both paths normalize to the same block walk, so a
15
- * silver stream carrying only text plans byte-identically to a bypass
16
- * stream (fixture 5).
12
+ * - ASSISTANT content comes from the fold (`inputs.result`) for the
13
+ * TRAILING turns the fold covers; earlier flat assistant entries (a
14
+ * rehydrated/persisted prefix the session's Reducer never saw) render as
15
+ * text slots in front (the turn-scoped refinement see
16
+ * `TranscriptInputs.result`). Without a fold, flat entries plus the
17
+ * in-flight `assistantText` own everything. Both paths normalize to the
18
+ * same block walk, so a silver stream carrying only text plans
19
+ * byte-identically to a bypass stream (fixture 5).
17
20
  * - Interleaving is conversational alternation (user[i] then assistant[i]) —
18
21
  * the transcript invariant of a request/reply chat. Finer-grained
19
22
  * interleaving (true seq-ordering of history cards inside turns) needs
@@ -29,13 +32,15 @@
29
32
  * append ordinals, so every existing key survives each re-plan.
30
33
  */
31
34
  import type { AgBlock, AgReduceResult, JsonValue } from "@silverprotocol/core";
32
- import { snapshotViewMount, toolResultViewMount, uiLocator, type ViewMount } from "@guuey/mcp-apps-host";
35
+ import { snapshotViewMount, toolResultLocator, toolResultViewMount, type ViewMount } from "@guuey/mcp-apps-host";
33
36
  import type { TranscriptPolicy } from "./policy.js";
37
+ import { grantModeDisplay } from "./hitl.js";
34
38
  import type {
35
39
  CitationsItem,
36
40
  DataResultItem,
37
41
  DisplayItem,
38
42
  ItemKey,
43
+ NoticeItem,
39
44
  StatusLineItem,
40
45
  ToolGroupItem,
41
46
  ToolItem,
@@ -44,6 +49,7 @@ import type {
44
49
  TranscriptPlan,
45
50
  UnknownItem,
46
51
  ViewMountItem,
52
+ ViewRefItem,
47
53
  } from "./types.js";
48
54
 
49
55
  /** R5's giant threshold: above this byte count the state is `giant`. */
@@ -91,27 +97,77 @@ interface AssistantSource {
91
97
  stopped: boolean;
92
98
  }
93
99
 
94
- function foldAssistantSources(result: AgReduceResult, inFlight: boolean, aborted: boolean): AssistantSource[] {
100
+ /** A fold-borne `role:"notice"` row, anchored to the source it followed. */
101
+ interface FoldNotice {
102
+ text: string;
103
+ source: NoticeItem["source"];
104
+ /** Index into the returned sources of the message this notice followed; -1 = before any. */
105
+ afterSourceIndex: number;
106
+ }
107
+
108
+ /** R16 notice text: the message's text blocks, joined (non-text blocks carry no notice prose). */
109
+ function noticeText(content: readonly AgBlock[]): string {
110
+ return content
111
+ .filter((b): b is Extract<AgBlock, { type: "text" }> => b.type === "text")
112
+ .map((b) => b.text)
113
+ .join("\n");
114
+ }
115
+
116
+ function foldAssistantSources(
117
+ result: AgReduceResult,
118
+ inFlight: boolean,
119
+ aborted: boolean,
120
+ ): { sources: AssistantSource[]; notices: FoldNotice[] } {
95
121
  // Real pod folds carry `tool-result` blocks in separate `role: "tool"`
96
122
  // messages between assistant turns (the production ggui-render capture is
97
123
  // the receipt — guuey#135 3b widget convergence found this): an
98
124
  // assistant-only filter orphans every call and drops every mount. A tool
99
125
  // message's blocks belong to the PRECEDING assistant slot's walk, exactly
100
126
  // the whole-fold message order the retired first-party renderers used.
127
+ //
128
+ // LIVE detection is turn-identity-based where the fold carries it: while
129
+ // a turn is in flight, an assistant message whose `turnId` maps to a turn
130
+ // record WITHOUT an `outcome` belongs to the open turn — settled earlier
131
+ // turns stay settled even mid-session (what makes the tail alignment
132
+ // below count them correctly). Nothing is live once the status is back to
133
+ // `ready`, whatever the records say (a fold that missed `turn.done` must
134
+ // not pin a settled turn open forever). Folds without turn identity (a
135
+ // hand-driven fold that never saw `turn.*` lifecycle) fall back to the
136
+ // last-source-is-live-iff-in-flight heuristic — the pre-refinement
137
+ // behaviour, kept so identity-less folds plan exactly as before.
138
+ const openTurns = new Set(
139
+ result.turns.filter((t) => t.outcome === undefined).map((t) => t.turnId),
140
+ );
141
+ const hasTurnIdentity = result.messages.some(
142
+ (m) => m.role === "assistant" && m.turnId !== undefined,
143
+ );
101
144
  const sources: AssistantSource[] = [];
145
+ const notices: FoldNotice[] = [];
102
146
  for (const m of result.messages) {
103
147
  if (m.role === "assistant") {
104
- sources.push({ blocks: [...m.content], live: false, stopped: false });
148
+ sources.push({
149
+ blocks: [...m.content],
150
+ live: inFlight && hasTurnIdentity && m.turnId !== undefined && openTurns.has(m.turnId),
151
+ stopped: false,
152
+ });
105
153
  } else if (m.role === "tool" && sources.length > 0) {
106
154
  sources[sources.length - 1]!.blocks.push(...m.content);
155
+ } else if (m.role === "notice") {
156
+ // R16 (spec draft.2): a session annotation — a PEER row, never folded
157
+ // into assistant content; it anchors after the source it followed.
158
+ notices.push({
159
+ text: noticeText(m.content),
160
+ source: m.noticeSource ?? null,
161
+ afterSourceIndex: sources.length - 1,
162
+ });
107
163
  }
108
164
  }
109
165
  const last = sources[sources.length - 1];
110
166
  if (last) {
111
- last.live = inFlight;
167
+ if (!hasTurnIdentity) last.live = inFlight;
112
168
  last.stopped = aborted;
113
169
  }
114
- return sources;
170
+ return { sources, notices };
115
171
  }
116
172
 
117
173
  function flatAssistantSources(inputs: TranscriptInputs, inFlight: boolean): AssistantSource[] {
@@ -355,7 +411,7 @@ function planAssistantSource(
355
411
  // `result` is narrowed by `mount !== undefined` above; the scope
356
412
  // is the PERSISTED locator (`uiData.resourceUri`), never the
357
413
  // mount payload's own uri (synthetic for a ggui shell).
358
- actionScope: result ? (uiLocator(result.uiData) ?? null) : null,
414
+ actionScope: result ? (toolResultLocator(result) ?? null) : null,
359
415
  };
360
416
  view.label = viewLabel(view, policy);
361
417
  items.push(view);
@@ -621,12 +677,73 @@ export function planTranscript(
621
677
 
622
678
  const inFlight = inputs.status !== "ready";
623
679
  const users = inputs.messages.filter((m) => m.role === "user");
624
- const assistants = inputs.result
625
- ? foldAssistantSources(inputs.result, inFlight, inputs.aborted === true)
626
- : flatAssistantSources(inputs, inFlight);
680
+ // R16 notice rows are session annotations anchored between turns: a
681
+ // flat notice renders after the conversation slot of the user turn it
682
+ // followed (usersSeen-1; -1 = before the conversation). They are PEER
683
+ // rows — excluded from both the user pairing and the assistant seam
684
+ // count, so the fold alignment below never sees them.
685
+ const notices: Array<{ key: string; text: string; source: NoticeItem["source"]; afterSlot: number }> = [];
686
+ if (policy.notice.show) {
687
+ let usersSeen = 0;
688
+ inputs.messages.forEach((m, i) => {
689
+ if (m.role === "user") usersSeen += 1;
690
+ else if (m.role === "notice") {
691
+ notices.push({ key: `n${i}`, text: m.text, source: m.noticeSource ?? null, afterSlot: usersSeen - 1 });
692
+ }
693
+ });
694
+ }
695
+ // Turn-scoped fold composition (guuey#135 kit refinement): the fold owns
696
+ // the TRAILING assistant turns it actually covers — its settled sources
697
+ // replace that many trailing flat assistant entries; any EARLIER flat
698
+ // entries (a rehydrated/persisted prefix the session's Reducer never saw)
699
+ // keep rendering as flat text slots in front. When the fold spans the
700
+ // whole conversation (a pure-live session — the corpus's world), the kept
701
+ // prefix is empty and the plan is byte-identical to the old wholesale
702
+ // replace. Alignment is by COUNT (trailing flat entries ↔ settled fold
703
+ // sources): flat rows carry no turn identity, so a persisted CARD-ONLY
704
+ // turn inside the fold's span (an empty-text assistant row the assembler
705
+ // dropped) can offset the seam by one — a documented approximation, same
706
+ // class as the header's read-plane-seq note, resolved for the visible
707
+ // cards by the actionScope dedupe below.
708
+ let assistants: AssistantSource[];
709
+ if (inputs.result) {
710
+ const fold = foldAssistantSources(inputs.result, inFlight, inputs.aborted === true);
711
+ const foldSources = fold.sources;
712
+ const settledFoldCount = foldSources.filter((s) => !s.live).length;
713
+ const flatSettled = inputs.messages.filter((m) => m.role === "assistant");
714
+ const keep = Math.max(0, flatSettled.length - settledFoldCount);
715
+ assistants = [
716
+ ...flatSettled.slice(0, keep).map(
717
+ (m): AssistantSource => ({
718
+ blocks: [{ type: "text", text: m.text }],
719
+ live: false,
720
+ stopped: false,
721
+ }),
722
+ ),
723
+ ...foldSources,
724
+ ];
725
+ if (policy.notice.show) {
726
+ // Fold-borne notices anchor to the fold source they followed, which
727
+ // sits at global slot `keep + afterSourceIndex` after the seam.
728
+ fold.notices.forEach((n, i) => {
729
+ notices.push({ key: `nf${i}`, text: n.text, source: n.source, afterSlot: keep + n.afterSourceIndex });
730
+ });
731
+ }
732
+ } else {
733
+ assistants = flatAssistantSources(inputs, inFlight);
734
+ }
627
735
 
628
736
  const slots = Math.max(users.length, assistants.length);
629
737
  const conversation: DisplayItem[] = [];
738
+ const noticeItem = (n: (typeof notices)[number]): NoticeItem => ({
739
+ kind: "notice",
740
+ key: n.key,
741
+ expanded: true,
742
+ text: n.text,
743
+ source: n.source,
744
+ sourceLabel: policy.debugDetail && n.source !== null ? n.source : null,
745
+ });
746
+ for (const n of notices) if (n.afterSlot < 0) conversation.push(noticeItem(n));
630
747
  for (let slot = 0; slot < slots; slot++) {
631
748
  const user = users[slot];
632
749
  if (user) {
@@ -648,16 +765,35 @@ export function planTranscript(
648
765
  if (assistant) {
649
766
  conversation.push(...planAssistantSource(assistant, slot, inputs, policy, overrides));
650
767
  }
768
+ for (const n of notices) if (n.afterSlot === slot) conversation.push(noticeItem(n));
651
769
  }
652
770
  items.push(...groupTools(conversation, policy, overrides));
653
771
 
654
772
  // R13 — persisted cards, seq order. (Position: after the settled
655
773
  // conversation; true in-turn interleave needs read-plane seqs the flat
656
774
  // surface lacks — the 3b assemblers own that refinement.)
775
+ //
776
+ // Dedupe against LIVE mounts by actionScope (the persisted `ui://`
777
+ // identity both sides carry): a session whose fold still holds a turn the
778
+ // read plane has ALSO persisted — the #192 adopted turn, a thread view's
779
+ // post-handoff refetch — would otherwise render the same card twice. The
780
+ // fold's view row owns it (richer phase state); the history snapshot
781
+ // renders only when no live mount claims its identity.
782
+ const liveViewScopes = new Set<string>();
783
+ for (const item of items) {
784
+ if (item.kind === "view" && item.actionScope !== null) liveViewScopes.add(item.actionScope);
785
+ }
657
786
  const cards = [...(inputs.historyCards ?? [])].sort((a, b) => a.seq - b.seq);
658
787
  for (const card of cards) {
659
788
  const key = `card.${card.seq}`;
660
789
  const mount = snapshotViewMount(card.cardSnapshot);
790
+ const scope =
791
+ mount === undefined
792
+ ? null
793
+ : mount.channel === "locator"
794
+ ? mount.resourceUri
795
+ : mount.resource.uri;
796
+ if (scope !== null && liveViewScopes.has(scope)) continue; // live mount owns it
661
797
  const view: ViewMountItem = {
662
798
  kind: "view",
663
799
  key,
@@ -668,20 +804,42 @@ export function planTranscript(
668
804
  label: null,
669
805
  attribution: null,
670
806
  toolTitle: null,
671
- actionScope:
672
- mount === undefined
673
- ? null
674
- : mount.channel === "locator"
675
- ? mount.resourceUri
676
- : mount.resource.uri,
807
+ actionScope: scope,
677
808
  };
678
809
  view.label = viewLabel(view, policy);
679
810
  items.push(view);
680
811
  }
681
812
 
682
- // R10 — prompts, in input order.
813
+ // R10 — prompts, in input order (narrow on `kind`: the guuey-wire
814
+ // profile arm vs the AgJSON hitl arm, spec draft.2).
683
815
  for (const prompt of inputs.prompts) {
684
816
  const key = `p.${prompt.id}`;
817
+ if (prompt.kind === "hitl") {
818
+ const chosen =
819
+ prompt.grantModeId !== undefined
820
+ ? (prompt.ask.grantModes ?? []).find((m) => m.id === prompt.grantModeId)
821
+ : undefined;
822
+ items.push({
823
+ kind: "prompt",
824
+ key,
825
+ promptId: prompt.id,
826
+ // `cancelled` collapses to a dismissed record but stays expandable —
827
+ // guuey's re-askable ruling (silverprotocol#16); pending opens.
828
+ expanded: resolveExpanded(key, prompt.state === "pending", overrides),
829
+ promptKind: "hitl",
830
+ ask: prompt.ask,
831
+ message: prompt.ask.message ?? null,
832
+ askKind: prompt.ask.kind,
833
+ grantModes: prompt.ask.grantModes ?? [],
834
+ state: prompt.state,
835
+ chosenModeId: prompt.grantModeId ?? null,
836
+ chosenModeLabel: chosen !== undefined ? grantModeDisplay(chosen) : null,
837
+ raw: policy.prompt.rawPayload
838
+ ? { askId: prompt.ask.askId, kind: prompt.ask.kind, state: prompt.state, grantModeId: prompt.grantModeId ?? null }
839
+ : null,
840
+ });
841
+ continue;
842
+ }
685
843
  items.push({
686
844
  kind: "prompt",
687
845
  key,
@@ -731,10 +889,65 @@ export function planTranscript(
731
889
  });
732
890
  }
733
891
 
892
+ // guuey#204 "promote and reference": the ONE mount a host-owned stage
893
+ // shows (`inputs.promotedViewKey`) renders as a compact reference chip
894
+ // instead of a second live app instance. A key matching nothing (stale
895
+ // host state) or an expired mount promotes nothing — the honest no-op;
896
+ // the next render's key corrects it. The chip keeps the mount's key, so
897
+ // overrides and phase reports survive a swap back to the full mount.
898
+ const promoted = inputs.promotedViewKey;
899
+ const finalItems =
900
+ promoted === undefined
901
+ ? items
902
+ : items.map((item): DisplayItem => {
903
+ if (item.kind !== "view" || item.key !== promoted) return item;
904
+ if (item.phase === "expired" || item.mount === null) return item;
905
+ const title = item.toolTitle ?? policy.strings.viewRefFallbackTitle;
906
+ const ref: ViewRefItem = {
907
+ kind: "viewRef",
908
+ key: item.key,
909
+ expanded: false,
910
+ title,
911
+ label: policy.strings.viewPromoted(title),
912
+ };
913
+ return ref;
914
+ });
915
+
734
916
  return {
735
- items,
917
+ items: finalItems,
736
918
  status: deriveStatus(inputs, policy),
737
919
  recovery:
738
920
  inputs.adopted === true && policy.debugDetail ? policy.strings.recoveredFromHistory : null,
739
921
  };
740
922
  }
923
+
924
+ /**
925
+ * The plan key of the NEWEST mountable view — what a "most recent card"
926
+ * stage (the widget's CanvasPane) will show, derived in the same family
927
+ * the plan's own emission uses: live fold mounts (transcript order, last
928
+ * wins) outrank history cards (ascending `seq`). `undefined` when nothing
929
+ * narrows to a mountable resource. Hosts pass the key back as
930
+ * `TranscriptInputs.promotedViewKey`; `channel` lets a host decline to
931
+ * promote a bare locator when no reader is wired (the stage would show
932
+ * nothing, and a chip must never point at an empty canvas).
933
+ */
934
+ export function newestViewKey(
935
+ inputs: Pick<TranscriptInputs, "result" | "historyCards">,
936
+ ): { key: string; channel: ViewMount["channel"] } | undefined {
937
+ let newest: { key: string; channel: ViewMount["channel"] } | undefined;
938
+ const cards = [...(inputs.historyCards ?? [])].sort((a, b) => a.seq - b.seq);
939
+ for (const card of cards) {
940
+ const mount = snapshotViewMount(card.cardSnapshot);
941
+ if (mount === undefined) continue;
942
+ newest = { key: `card.${card.seq}`, channel: mount.channel };
943
+ }
944
+ for (const message of inputs.result?.messages ?? []) {
945
+ for (const block of message.content) {
946
+ if (block.type !== "tool-result") continue;
947
+ const mount = toolResultViewMount(block);
948
+ if (mount === undefined) continue;
949
+ newest = { key: `view.${block.toolCallId}`, channel: mount.channel };
950
+ }
951
+ }
952
+ return newest;
953
+ }
package/src/policy.ts CHANGED
@@ -69,6 +69,8 @@ export interface TranscriptPolicy {
69
69
  };
70
70
  /** R12 — thresholds are chosen-not-measured (§10-F6; 3b validates vs #188 data). */
71
71
  status: { wakingMs: number; longStartMs: number };
72
+ /** R16 (spec draft.2) — notice rows; provenance facet shows under debug. */
73
+ notice: { show: boolean };
72
74
  /** R14. */
73
75
  compaction: { show: boolean };
74
76
  /** R15. */
@@ -95,6 +97,7 @@ function withOverrides(base: TranscriptPolicy, overrides?: Partial<TranscriptPol
95
97
  prompt: { ...base.prompt, ...(overrides.prompt ?? {}) },
96
98
  error: { ...base.error, ...(overrides.error ?? {}) },
97
99
  status: { ...base.status, ...(overrides.status ?? {}) },
100
+ notice: { ...base.notice, ...(overrides.notice ?? {}) },
98
101
  compaction: { ...base.compaction, ...(overrides.compaction ?? {}) },
99
102
  unknown: { ...base.unknown, ...(overrides.unknown ?? {}) },
100
103
  };
@@ -115,6 +118,7 @@ function calmBase(): TranscriptPolicy {
115
118
  media: { inlineImageCapRem: 20, chipOnly: false },
116
119
  code: { capRem: 16, wrap: false },
117
120
  citations: { style: "chips" },
121
+ notice: { show: true },
118
122
  prompt: { placement: "inline", rawPayload: false },
119
123
  error: { verbatim: false, copyByCode: {}, verbatimCodes: [] },
120
124
  status: { wakingMs: 2500, longStartMs: 15_000 },
@@ -38,6 +38,7 @@ import type {
38
38
  HistoryBoundaryItem,
39
39
  ItemKey,
40
40
  MediaItem,
41
+ NoticeItem,
41
42
  PromptItem,
42
43
  ReasoningItem,
43
44
  StatusLineItem,
@@ -47,6 +48,7 @@ import type {
47
48
  UserMessageItem,
48
49
  TextItem,
49
50
  ViewMountItem,
51
+ ViewRefItem,
50
52
  } from "../types.js";
51
53
  import { Markdown } from "./markdown.js";
52
54
 
@@ -57,21 +59,56 @@ export interface TranscriptItemContext {
57
59
  onToggle: (key: ItemKey) => void;
58
60
  /** R0 failed-send retry. */
59
61
  onRetry?: (item: UserMessageItem) => void;
60
- /** R10 prompt actions — the host owns what accept/decline DO. */
61
- onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
62
+ /**
63
+ * R10 prompt actions the host owns what accept/decline DO. A hitl card
64
+ * with declared grant modes reports the pick as `{ grantModeId }` (spec
65
+ * draft.2); plain accepts stay the `"accept"` string.
66
+ */
67
+ onPromptAction?: (
68
+ item: PromptItem,
69
+ action: "accept" | "decline" | "dismiss" | { grantModeId: string },
70
+ ) => void;
62
71
  /** R11 action slots (sign-in / upgrade / retry affordances). */
63
72
  onErrorAction?: (item: ErrorItem) => void;
64
73
  /** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
65
74
  resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
66
75
  /** R6: live phase reports wired back into the next plan. */
67
76
  onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
68
- /** R6 pass-through (sandbox overrides etc. — policy-gated by the host). */
69
- viewProps?: Pick<
70
- GuueyViewProps,
71
- "hostCapabilities" | "hostInfo" | "hostContext" | "onCallTool" | "negotiationTimeoutMs" | "dangerouslyAddSandboxFlags" | "allow"
72
- >;
77
+ /**
78
+ * guuey#204: a promoted-view reference chip was activated — the host
79
+ * focuses/reveals its stage (canvas). Absent ⇒ the chip renders as a
80
+ * non-interactive label.
81
+ */
82
+ onViewRef?: (item: ViewRefItem) => void;
83
+ /**
84
+ * R6 pass-through (sandbox overrides, the two-origin mount, relay hooks
85
+ * — policy-gated by the host). Static, or a FUNCTION of the mount being
86
+ * rendered: view configuration legitimately varies per mount — the
87
+ * relay-page URL picks a per-channel egress grant (`?channel=`), and a
88
+ * `tools/call` hook scopes to the item's persisted locator (guuey#158) —
89
+ * so the function form receives the item and its resolved mount and is
90
+ * called only when something actually mounts. Static keeps working
91
+ * unchanged.
92
+ */
93
+ viewProps?: ViewSlotProps | ((item: ViewMountItem, mount: ResolvedViewMount) => ViewSlotProps);
73
94
  }
74
95
 
96
+ /** The per-view slice of `GuueyViewProps` a transcript may configure. */
97
+ export type ViewSlotProps = Pick<
98
+ GuueyViewProps,
99
+ | "hostCapabilities"
100
+ | "hostInfo"
101
+ | "hostContext"
102
+ | "onCallTool"
103
+ | "onReadResource"
104
+ | "onSizeChanged"
105
+ | "negotiationTimeoutMs"
106
+ | "dangerouslyAddSandboxFlags"
107
+ | "allow"
108
+ | "sandboxPageUrl"
109
+ | "autoResize"
110
+ >;
111
+
75
112
  interface ItemProps<T> {
76
113
  item: T;
77
114
  ctx: TranscriptItemContext;
@@ -318,11 +355,15 @@ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode
318
355
  </div>
319
356
  );
320
357
  }
358
+ // The function form resolves against the ACTUAL mount (per-channel relay
359
+ // URLs, per-locator action scoping); it runs only when something mounts.
360
+ const viewProps =
361
+ typeof ctx.viewProps === "function" ? ctx.viewProps(item, mount) : (ctx.viewProps ?? {});
321
362
  return (
322
363
  <div className="guuey-chat-view">
323
364
  <GuueyView
324
365
  mount={mount}
325
- {...(ctx.viewProps ?? {})}
366
+ {...viewProps}
326
367
  onPhaseChange={(phase) => ctx.onViewPhase(item.key, phase)}
327
368
  />
328
369
  {item.attribution !== null ? (
@@ -332,6 +373,31 @@ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode
332
373
  );
333
374
  }
334
375
 
376
+ /**
377
+ * guuey#204: the "promote and reference" chip — stands in for the one
378
+ * mount the host's stage shows. A real button when the host wires
379
+ * `onViewRef` (keyboard-accessible for free); a plain label otherwise.
380
+ */
381
+ export function DefaultViewRef({ item, ctx }: ItemProps<ViewRefItem>): ReactNode {
382
+ if (ctx.onViewRef === undefined) {
383
+ return (
384
+ <p className="guuey-chat-view-ref" role="note">
385
+ {item.label}
386
+ </p>
387
+ );
388
+ }
389
+ const onViewRef = ctx.onViewRef;
390
+ return (
391
+ <button
392
+ type="button"
393
+ className="guuey-chat-view-ref guuey-chat-view-ref-button"
394
+ onClick={() => onViewRef(item)}
395
+ >
396
+ {item.label}
397
+ </button>
398
+ );
399
+ }
400
+
335
401
  // ─── R7 ────────────────────────────────────────────────────────────────────
336
402
 
337
403
  /** Inline images allow https URLs and image/* base64 data — nothing else. */
@@ -439,6 +505,72 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
439
505
  wasPending.current = item.state === "pending";
440
506
  }, [item.state]);
441
507
 
508
+ if (item.promptKind === "hitl") {
509
+ const s = ctx.strings;
510
+ // Settled records: resolved shows the CHOSEN MODE'S LABEL (ids are
511
+ // echo-only identity — asker-scoped semantics, spec §7); declined is
512
+ // the durable deny. `cancelled` is guuey's re-askable dismissal: it
513
+ // collapses to a record but stays answerable when expanded.
514
+ if (item.state === "resolved" || item.state === "declined") {
515
+ return (
516
+ <p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
517
+ {item.state === "resolved"
518
+ ? item.chosenModeLabel !== null
519
+ ? s.promptAnsweredWith(item.chosenModeLabel)
520
+ : s.promptAccept
521
+ : s.promptDeclinedRecord}
522
+ </p>
523
+ );
524
+ }
525
+ const answerable = item.state === "pending" || (item.state === "cancelled" && item.expanded);
526
+ return (
527
+ <div className="guuey-chat-prompt" role="group">
528
+ {item.state === "cancelled" && (
529
+ <button
530
+ type="button"
531
+ className="guuey-chat-prompt-record guuey-chat-prompt-cancelled"
532
+ aria-expanded={item.expanded}
533
+ onClick={() => ctx.onToggle(item.key)}
534
+ >
535
+ {s.promptDismissed}
536
+ </button>
537
+ )}
538
+ {answerable && (
539
+ <>
540
+ {item.message !== null && <p className="guuey-chat-prompt-ask">{item.message}</p>}
541
+ <div className="guuey-chat-prompt-actions">
542
+ {item.grantModes.length === 0 ? (
543
+ <button
544
+ ref={firstAction}
545
+ type="button"
546
+ className="guuey-chat-prompt-accept"
547
+ onClick={() => ctx.onPromptAction?.(item, "accept")}
548
+ >
549
+ {s.promptAccept}
550
+ </button>
551
+ ) : (
552
+ item.grantModes.map((mode, i) => (
553
+ <button
554
+ key={mode.id}
555
+ ref={i === 0 ? firstAction : undefined}
556
+ type="button"
557
+ className="guuey-chat-prompt-accept"
558
+ title={mode.description}
559
+ onClick={() => ctx.onPromptAction?.(item, { grantModeId: mode.id })}
560
+ >
561
+ {mode.label ?? mode.id}
562
+ </button>
563
+ ))
564
+ )}
565
+ <button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
566
+ {s.promptDecline}
567
+ </button>
568
+ </div>
569
+ </>
570
+ )}
571
+ </div>
572
+ );
573
+ }
442
574
  if (item.state !== "pending") {
443
575
  return (
444
576
  <p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
@@ -473,6 +605,25 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
473
605
  );
474
606
  }
475
607
 
608
+ // ─── R16 ──────────────────────────────────────────────────────────────────
609
+
610
+ /**
611
+ * A `role:"notice"` session annotation (spec draft.2) — a labeled,
612
+ * non-conversational row that must never read as agent-authored. Calm
613
+ * shows the quiet label; debug appends the provenance facet verbatim.
614
+ */
615
+ export function DefaultNotice({ item, ctx }: ItemProps<NoticeItem>): ReactNode {
616
+ return (
617
+ <div className="guuey-chat-notice" role="note">
618
+ <span className="guuey-chat-notice-label">
619
+ {ctx.strings.noticeLabel}
620
+ {item.sourceLabel !== null ? ` · ${item.sourceLabel}` : ""}
621
+ </span>
622
+ {item.text !== "" && <span className="guuey-chat-notice-text">{item.text}</span>}
623
+ </div>
624
+ );
625
+ }
626
+
476
627
  // ─── R11 ───────────────────────────────────────────────────────────────────
477
628
 
478
629
  export function DefaultError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
@@ -554,10 +705,12 @@ export interface TranscriptComponents {
554
705
  toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
555
706
  dataResult: ComponentType<ItemProps<DataResultItem>>;
556
707
  view: ComponentType<ItemProps<ViewMountItem>>;
708
+ viewRef: ComponentType<ItemProps<ViewRefItem>>;
557
709
  media: ComponentType<ItemProps<MediaItem>>;
558
710
  code: ComponentType<ItemProps<CodeItem>>;
559
711
  citations: ComponentType<ItemProps<CitationsItem>>;
560
712
  prompt: ComponentType<ItemProps<PromptItem>>;
713
+ notice: ComponentType<ItemProps<NoticeItem>>;
561
714
  error: ComponentType<ItemProps<ErrorItem>>;
562
715
  history: ComponentType<ItemProps<HistoryBoundaryItem>>;
563
716
  compaction: ComponentType<ItemProps<CompactionItem>>;
@@ -573,10 +726,12 @@ export const defaultTranscriptComponents: TranscriptComponents = {
573
726
  toolGroup: DefaultToolGroup,
574
727
  dataResult: DefaultDataResult,
575
728
  view: DefaultView,
729
+ viewRef: DefaultViewRef,
576
730
  media: DefaultMedia,
577
731
  code: DefaultCode,
578
732
  citations: DefaultCitations,
579
733
  prompt: DefaultPrompt,
734
+ notice: DefaultNotice,
580
735
  error: DefaultError,
581
736
  history: DefaultHistoryBoundary,
582
737
  compaction: DefaultCompaction,
@@ -619,6 +774,10 @@ export function renderItem(
619
774
  const C = components.view;
620
775
  return <C key={item.key} item={item} ctx={ctx} />;
621
776
  }
777
+ case "viewRef": {
778
+ const C = components.viewRef;
779
+ return <C key={item.key} item={item} ctx={ctx} />;
780
+ }
622
781
  case "media": {
623
782
  const C = components.media;
624
783
  return <C key={item.key} item={item} ctx={ctx} />;
@@ -635,6 +794,10 @@ export function renderItem(
635
794
  const C = components.prompt;
636
795
  return <C key={item.key} item={item} ctx={ctx} />;
637
796
  }
797
+ case "notice": {
798
+ const C = components.notice;
799
+ return <C key={item.key} item={item} ctx={ctx} />;
800
+ }
638
801
  case "error": {
639
802
  const C = components.error;
640
803
  return <C key={item.key} item={item} ctx={ctx} />;