@flamingo-stack/openframe-frontend-core 0.0.512-1640.4221.1 → 0.0.512

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 (100) hide show
  1. package/dist/{chunk-OOJLER5V.cjs → chunk-2WWDZR4D.cjs} +31 -31
  2. package/dist/{chunk-OOJLER5V.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
  3. package/dist/{chunk-N3CQTN5T.cjs → chunk-2XL7UK5Y.cjs} +5 -5
  4. package/dist/{chunk-N3CQTN5T.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
  5. package/dist/{chunk-JEKWIGG6.js → chunk-4NFDSMB5.js} +4 -4
  6. package/dist/{chunk-BXTVXQGZ.cjs → chunk-5JBDT2MQ.cjs} +7 -7
  7. package/dist/{chunk-BXTVXQGZ.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
  8. package/dist/{chunk-GVMHJK3B.cjs → chunk-67YDSTH7.cjs} +9 -9
  9. package/dist/{chunk-GVMHJK3B.cjs.map → chunk-67YDSTH7.cjs.map} +1 -1
  10. package/dist/{chunk-XYD3Y7WU.js → chunk-6LMTBNLN.js} +2 -2
  11. package/dist/{chunk-ZLCYLZFL.cjs → chunk-7VBEOGCR.cjs} +61 -61
  12. package/dist/{chunk-ZLCYLZFL.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
  13. package/dist/{chunk-OEPGR7FT.cjs → chunk-AWLFJMOJ.cjs} +37 -37
  14. package/dist/{chunk-OEPGR7FT.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
  15. package/dist/{chunk-UXEVYNSX.cjs → chunk-DQDPDUI3.cjs} +4 -4
  16. package/dist/{chunk-UXEVYNSX.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
  17. package/dist/{chunk-MWJARB2T.cjs → chunk-F2YI5N7E.cjs} +13 -13
  18. package/dist/{chunk-MWJARB2T.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
  19. package/dist/{chunk-BH24GEIY.js → chunk-FI35LJ3L.js} +2 -2
  20. package/dist/{chunk-PDPNJJLB.cjs → chunk-GOFTC2HV.cjs} +431 -489
  21. package/dist/chunk-GOFTC2HV.cjs.map +1 -0
  22. package/dist/{chunk-OYYHTO7U.js → chunk-HGZPH7S5.js} +2 -2
  23. package/dist/{chunk-VIXKP7G7.js → chunk-IHNUMRMU.js} +2 -2
  24. package/dist/{chunk-MCFMOSY6.js → chunk-IK2WRYVA.js} +4 -4
  25. package/dist/{chunk-ORNBZJUZ.cjs → chunk-JIW5JI3E.cjs} +3 -3
  26. package/dist/{chunk-ORNBZJUZ.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
  27. package/dist/{chunk-MSMSMXST.js → chunk-JKAX5K7C.js} +5 -5
  28. package/dist/{chunk-AXS2XECN.js → chunk-JO3NPEK6.js} +2 -2
  29. package/dist/{chunk-NLGPA3VY.js → chunk-JOFGJYIT.js} +2 -2
  30. package/dist/{chunk-PPVWJQTF.js → chunk-OT4RV6WY.js} +6 -6
  31. package/dist/{chunk-Z3MUSW3I.cjs → chunk-RJXI25TJ.cjs} +26 -26
  32. package/dist/{chunk-Z3MUSW3I.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
  33. package/dist/{chunk-SEZR7FMU.cjs → chunk-SG25J6LV.cjs} +87 -87
  34. package/dist/{chunk-SEZR7FMU.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
  35. package/dist/{chunk-Z32PJFCD.js → chunk-V5WLIFKN.js} +312 -370
  36. package/dist/{chunk-Z32PJFCD.js.map → chunk-V5WLIFKN.js.map} +1 -1
  37. package/dist/{chunk-DPXBR7O2.cjs → chunk-VVXUUBML.cjs} +26 -26
  38. package/dist/{chunk-DPXBR7O2.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
  39. package/dist/{chunk-O2XT6VUS.js → chunk-VZX3SSFL.js} +2 -2
  40. package/dist/{chunk-7TKJAGBO.cjs → chunk-WVZHCDB5.cjs} +11 -11
  41. package/dist/{chunk-7TKJAGBO.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
  42. package/dist/{chunk-ELED5PW4.js → chunk-XG7FLAKQ.js} +2 -2
  43. package/dist/{chunk-YIPZT3XR.js → chunk-ZIQMVAGA.js} +2 -2
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  47. package/dist/components/chat/index.cjs +2 -2
  48. package/dist/components/chat/index.js +1 -1
  49. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  50. package/dist/components/chat/stream/message-mutations.d.ts +33 -5
  51. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  52. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  53. package/dist/components/contact/index.cjs +3 -3
  54. package/dist/components/contact/index.js +2 -2
  55. package/dist/components/docs/index.cjs +5 -5
  56. package/dist/components/docs/index.js +4 -4
  57. package/dist/components/embeds/index.cjs +3 -3
  58. package/dist/components/embeds/index.js +2 -2
  59. package/dist/components/faq/index.cjs +3 -3
  60. package/dist/components/faq/index.js +2 -2
  61. package/dist/components/features/index.cjs +2 -2
  62. package/dist/components/features/index.js +1 -1
  63. package/dist/components/help-center-pages/index.cjs +22 -22
  64. package/dist/components/help-center-pages/index.js +13 -13
  65. package/dist/components/index.cjs +132 -132
  66. package/dist/components/index.js +11 -11
  67. package/dist/components/meeting-scheduler/index.cjs +34 -34
  68. package/dist/components/meeting-scheduler/index.js +3 -3
  69. package/dist/components/navigation/index.cjs +2 -2
  70. package/dist/components/navigation/index.js +1 -1
  71. package/dist/components/onboarding-guides/index.cjs +5 -5
  72. package/dist/components/onboarding-guides/index.js +4 -4
  73. package/dist/components/related-content/index.cjs +3 -3
  74. package/dist/components/related-content/index.js +2 -2
  75. package/dist/components/tickets/index.cjs +6 -6
  76. package/dist/components/tickets/index.js +5 -5
  77. package/dist/components/ui/index.cjs +2 -2
  78. package/dist/components/ui/index.js +1 -1
  79. package/dist/index.cjs +2 -2
  80. package/dist/index.js +1 -1
  81. package/package.json +1 -1
  82. package/src/components/chat/entity-cards/dispatch.tsx +87 -98
  83. package/src/components/chat/hooks/use-sse-chat-adapter.ts +15 -15
  84. package/src/components/chat/stream/chat-stream-reducer.ts +6 -0
  85. package/src/components/chat/stream/message-mutations.ts +140 -116
  86. package/src/components/chat/utils/message-segment-accumulator.ts +19 -27
  87. package/dist/chunk-PDPNJJLB.cjs.map +0 -1
  88. /package/dist/{chunk-JEKWIGG6.js.map → chunk-4NFDSMB5.js.map} +0 -0
  89. /package/dist/{chunk-XYD3Y7WU.js.map → chunk-6LMTBNLN.js.map} +0 -0
  90. /package/dist/{chunk-BH24GEIY.js.map → chunk-FI35LJ3L.js.map} +0 -0
  91. /package/dist/{chunk-OYYHTO7U.js.map → chunk-HGZPH7S5.js.map} +0 -0
  92. /package/dist/{chunk-VIXKP7G7.js.map → chunk-IHNUMRMU.js.map} +0 -0
  93. /package/dist/{chunk-MCFMOSY6.js.map → chunk-IK2WRYVA.js.map} +0 -0
  94. /package/dist/{chunk-MSMSMXST.js.map → chunk-JKAX5K7C.js.map} +0 -0
  95. /package/dist/{chunk-AXS2XECN.js.map → chunk-JO3NPEK6.js.map} +0 -0
  96. /package/dist/{chunk-NLGPA3VY.js.map → chunk-JOFGJYIT.js.map} +0 -0
  97. /package/dist/{chunk-PPVWJQTF.js.map → chunk-OT4RV6WY.js.map} +0 -0
  98. /package/dist/{chunk-O2XT6VUS.js.map → chunk-VZX3SSFL.js.map} +0 -0
  99. /package/dist/{chunk-ELED5PW4.js.map → chunk-XG7FLAKQ.js.map} +0 -0
  100. /package/dist/{chunk-YIPZT3XR.js.map → chunk-ZIQMVAGA.js.map} +0 -0
@@ -1026,26 +1026,34 @@ const FINANCIAL_CARD_CONFIGS: Record<string, FinancialCardConfig> = {
1026
1026
  balance_sheet: { label: 'Balance sheet', icon: () => <BankIcon size={24} /> },
1027
1027
  cash_flow: { label: 'Cash flow', icon: () => <CoinsExchangeCurrencyIcon size={24} /> },
1028
1028
  }
1029
- function financialRegistryEntries(): Record<string, ChatCardRegistryEntry> {
1029
+ /** Expand a per-family config map into registry entries — ONE loop shell
1030
+ * for every card family (financial, github, program, roadmap); adding a
1031
+ * family is a config map + one `registryEntries(...)` call. */
1032
+ function registryEntries<C>(
1033
+ configs: Record<string, C>,
1034
+ build: (cfg: C) => ChatCardRegistryEntry,
1035
+ ): Record<string, ChatCardRegistryEntry> {
1030
1036
  const out: Record<string, ChatCardRegistryEntry> = {}
1031
- for (const [docType, cfg] of Object.entries(FINANCIAL_CARD_CONFIGS)) {
1032
- out[docType] = {
1033
- mode: 'no-fetch',
1034
- label: cfg.label,
1035
- bareInline: true,
1036
- render: (chatRef, opts) => (
1037
- <GenericFinancialChatCard
1038
- chatRef={chatRef}
1039
- icon={cfg.icon()}
1040
- isNewTab={opts.isNewTab}
1041
- discuss={opts.discuss}
1042
- />
1043
- ),
1044
- }
1045
- }
1037
+ for (const [docType, cfg] of Object.entries(configs)) out[docType] = build(cfg)
1046
1038
  return out
1047
1039
  }
1048
1040
 
1041
+ function financialRegistryEntries(): Record<string, ChatCardRegistryEntry> {
1042
+ return registryEntries(FINANCIAL_CARD_CONFIGS, (cfg) => ({
1043
+ mode: 'no-fetch',
1044
+ label: cfg.label,
1045
+ bareInline: true,
1046
+ render: (chatRef, opts) => (
1047
+ <GenericFinancialChatCard
1048
+ chatRef={chatRef}
1049
+ icon={cfg.icon()}
1050
+ isNewTab={opts.isNewTab}
1051
+ discuss={opts.discuss}
1052
+ />
1053
+ ),
1054
+ }))
1055
+ }
1056
+
1049
1057
  interface GitHubCardConfig {
1050
1058
  label: string
1051
1059
  kind: GitHubActivityKind
@@ -1059,19 +1067,15 @@ const GITHUB_CARD_CONFIGS: Record<string, GitHubCardConfig> = {
1059
1067
  github_pr_review_public: { label: 'GitHub review (public)', kind: 'pr_review' },
1060
1068
  }
1061
1069
  function githubRegistryEntries(): Record<string, ChatCardRegistryEntry> {
1062
- const out: Record<string, ChatCardRegistryEntry> = {}
1063
- for (const [docType, cfg] of Object.entries(GITHUB_CARD_CONFIGS)) {
1064
- out[docType] = {
1065
- mode: 'no-fetch',
1066
- label: cfg.label,
1067
- bareInline: true,
1068
- render: (chatRef, opts) => (
1069
- <GitHubChatCard chatRef={chatRef} kind={cfg.kind} isNewTab={opts.isNewTab}
1070
- discuss={opts.discuss} />
1071
- ),
1072
- }
1073
- }
1074
- return out
1070
+ return registryEntries(GITHUB_CARD_CONFIGS, (cfg) => ({
1071
+ mode: 'no-fetch',
1072
+ label: cfg.label,
1073
+ bareInline: true,
1074
+ render: (chatRef, opts) => (
1075
+ <GitHubChatCard chatRef={chatRef} kind={cfg.kind} isNewTab={opts.isNewTab}
1076
+ discuss={opts.discuss} />
1077
+ ),
1078
+ }))
1075
1079
  }
1076
1080
 
1077
1081
  type ProgramConfigKey = 'podcast' | 'webinar' | 'event'
@@ -1086,28 +1090,24 @@ const PROGRAM_CARD_CONFIGS: Record<string, ProgramCardConfig> = {
1086
1090
  event: { label: 'Event', configKey: 'event', contentRefType: 'event' },
1087
1091
  }
1088
1092
  function programRegistryEntries(): Record<string, ChatCardRegistryEntry> {
1089
- const out: Record<string, ChatCardRegistryEntry> = {}
1090
- for (const [docType, cfg] of Object.entries(PROGRAM_CARD_CONFIGS)) {
1091
- out[docType] = {
1092
- mode: 'fetch',
1093
- label: cfg.label,
1094
- contentRefType: cfg.contentRefType,
1095
- bareInline: true,
1096
- skeleton: () => <ProgramCardSkeleton size="sm" />,
1097
- render: (item, chatRef, opts) => (
1098
- <ProgramChatCard
1099
- item={item}
1100
- chatRef={chatRef}
1101
- isNewTab={opts.isNewTab}
1102
- discuss={opts.discuss}
1103
- configKey={cfg.configKey}
1104
- label={cfg.configKey.charAt(0).toUpperCase() + cfg.configKey.slice(1)}
1105
- ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}
1106
- />
1107
- ),
1108
- }
1109
- }
1110
- return out
1093
+ return registryEntries(PROGRAM_CARD_CONFIGS, (cfg) => ({
1094
+ mode: 'fetch',
1095
+ label: cfg.label,
1096
+ contentRefType: cfg.contentRefType,
1097
+ bareInline: true,
1098
+ skeleton: () => <ProgramCardSkeleton size="sm" />,
1099
+ render: (item, chatRef, opts) => (
1100
+ <ProgramChatCard
1101
+ item={item}
1102
+ chatRef={chatRef}
1103
+ isNewTab={opts.isNewTab}
1104
+ discuss={opts.discuss}
1105
+ configKey={cfg.configKey}
1106
+ label={cfg.configKey.charAt(0).toUpperCase() + cfg.configKey.slice(1)}
1107
+ ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}
1108
+ />
1109
+ ),
1110
+ }))
1111
1111
  }
1112
1112
 
1113
1113
  type RoadmapCardType = 'roadmap_item' | 'delivery_item' | 'internal_task'
@@ -1135,27 +1135,23 @@ const ROADMAP_CARD_CONFIGS: Record<string, RoadmapEntryConfig> = {
1135
1135
  },
1136
1136
  }
1137
1137
  function roadmapRegistryEntries(): Record<string, ChatCardRegistryEntry> {
1138
- const out: Record<string, ChatCardRegistryEntry> = {}
1139
- for (const [docType, cfg] of Object.entries(ROADMAP_CARD_CONFIGS)) {
1140
- out[docType] = {
1141
- mode: 'fetch',
1142
- label: cfg.label,
1143
- contentRefType: cfg.contentRefType,
1144
- bareInline: true,
1145
- ...(cfg.noComposedHref ? { noComposedHref: true } : {}),
1146
- skeleton: () => <RoadmapCardSkeleton size="sm" />,
1147
- render: (item, chatRef, opts) => (
1148
- <RoadmapChatCard
1149
- item={item}
1150
- chatRef={chatRef}
1151
- isNewTab={opts.isNewTab}
1152
- discuss={opts.discuss}
1153
- cardType={cfg.cardType}
1154
- />
1155
- ),
1156
- }
1157
- }
1158
- return out
1138
+ return registryEntries(ROADMAP_CARD_CONFIGS, (cfg) => ({
1139
+ mode: 'fetch',
1140
+ label: cfg.label,
1141
+ contentRefType: cfg.contentRefType,
1142
+ bareInline: true,
1143
+ ...(cfg.noComposedHref ? { noComposedHref: true } : {}),
1144
+ skeleton: () => <RoadmapCardSkeleton size="sm" />,
1145
+ render: (item, chatRef, opts) => (
1146
+ <RoadmapChatCard
1147
+ item={item}
1148
+ chatRef={chatRef}
1149
+ isNewTab={opts.isNewTab}
1150
+ discuss={opts.discuss}
1151
+ cardType={cfg.cardType}
1152
+ />
1153
+ ),
1154
+ }))
1159
1155
  }
1160
1156
 
1161
1157
  const CHAT_CARD_REGISTRY: Record<string, ChatCardRegistryEntry> = {
@@ -1668,6 +1664,23 @@ export function ChatCardLoader({
1668
1664
  {children}
1669
1665
  </ChatCardNavWrap>
1670
1666
  )
1667
+ // ONE render tail for both modes — bare-inline cards skip the discuss
1668
+ // wrapper; everything else gets the ChatCardWithDiscuss chrome. Kept
1669
+ // here (not per-branch) so the two paths can never drift on the
1670
+ // discuss-menu wiring.
1671
+ const finish = (node: React.ReactNode) =>
1672
+ entry.bareInline ? (
1673
+ <>{navWrap(node)}</>
1674
+ ) : (
1675
+ <ChatCardWithDiscuss
1676
+ chatRef={finalChatRef}
1677
+ onDiscuss={onDiscuss}
1678
+ onDisplay={onDisplay}
1679
+ displayAction={entry.displayAction}
1680
+ >
1681
+ {navWrap(node)}
1682
+ </ChatCardWithDiscuss>
1683
+ )
1671
1684
  if (entry.mode === 'no-fetch') {
1672
1685
  // Synthetic-ref gate. `chat-message-enhanced.tsx` builds a minimal
1673
1686
  // `{ type, id, title: cardId, url: null }` ChatRef when the LLM
@@ -1690,19 +1703,7 @@ export function ChatCardLoader({
1690
1703
  // Fetch-mode types already handle this gracefully: a synthetic
1691
1704
  // id leads to a fetch miss → `!item` → null at line ~1034.
1692
1705
  if (!finalChatRef.sourceRepo) return null
1693
- if (entry.bareInline) {
1694
- return navWrap(entry.render(finalChatRef, renderOpts))
1695
- }
1696
- return (
1697
- <ChatCardWithDiscuss
1698
- chatRef={finalChatRef}
1699
- onDiscuss={onDiscuss}
1700
- onDisplay={onDisplay}
1701
- displayAction={entry.displayAction}
1702
- >
1703
- {navWrap(entry.render(finalChatRef, renderOpts))}
1704
- </ChatCardWithDiscuss>
1705
- )
1706
+ return finish(entry.render(finalChatRef, renderOpts))
1706
1707
  }
1707
1708
  if (isLoading) return <>{entry.skeleton()}</>
1708
1709
  if (!item) {
@@ -1742,19 +1743,7 @@ export function ChatCardLoader({
1742
1743
  }
1743
1744
  return null
1744
1745
  }
1745
- if (entry.bareInline) {
1746
- return <>{navWrap(entry.render(item, finalChatRef, renderOpts))}</>
1747
- }
1748
- return (
1749
- <ChatCardWithDiscuss
1750
- chatRef={finalChatRef}
1751
- onDiscuss={onDiscuss}
1752
- onDisplay={onDisplay}
1753
- displayAction={entry.displayAction}
1754
- >
1755
- {navWrap(entry.render(item, finalChatRef, renderOpts))}
1756
- </ChatCardWithDiscuss>
1757
- )
1746
+ return finish(entry.render(item, finalChatRef, renderOpts))
1758
1747
  }
1759
1748
 
1760
1749
  // =============================================================================
@@ -254,21 +254,21 @@ export function useSseChatAdapter(
254
254
 
255
255
  // Approve/reject pass the transport's boolean outcome through so batch
256
256
  // approve-all loops can mark a FAILED row (expired proposal, network
257
- // error) instead of leaving its execution loader spinning.
258
- const cardApprove = useCallback((reqId?: string): void | Promise<boolean> => {
259
- if (!reqId) return
260
- return sendMessageRef.current('', {
261
- hidden: true,
262
- approvalAction: { proposalId: reqId, action: 'approve' },
263
- })
264
- }, [])
265
- const cardReject = useCallback((reqId?: string): void | Promise<boolean> => {
266
- if (!reqId) return
267
- return sendMessageRef.current('', {
268
- hidden: true,
269
- approvalAction: { proposalId: reqId, action: 'reject' },
270
- })
271
- }, [])
257
+ // error) instead of leaving its execution loader spinning. ONE factory
258
+ // the two handlers differ only in the action literal.
259
+ const cardResolve = useCallback(
260
+ (action: 'approve' | 'reject') =>
261
+ (reqId?: string): void | Promise<boolean> => {
262
+ if (!reqId) return
263
+ return sendMessageRef.current('', {
264
+ hidden: true,
265
+ approvalAction: { proposalId: reqId, action },
266
+ })
267
+ },
268
+ [],
269
+ )
270
+ const cardApprove = useMemo(() => cardResolve('approve'), [cardResolve])
271
+ const cardReject = useMemo(() => cardResolve('reject'), [cardResolve])
272
272
 
273
273
  const createReducerOptions = useCallback(
274
274
  (): ChatStreamReducerOptions => ({
@@ -1978,6 +1978,12 @@ export function createChatStreamReducer(
1978
1978
  setMessagesInternal(
1979
1979
  projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1980
1980
  )
1981
+ // Status-map mirror — kept in lockstep with the other two flip
1982
+ // paths (SSE approval-resolved at ~1240, NATS at ~1950): the map
1983
+ // restores status when a replayed approval-request event
1984
+ // re-renders the card, and a flip applied through THIS path must
1985
+ // survive that replay the same way.
1986
+ mirrorApprovalStatus(requestId, status)
1981
1987
  return segments
1982
1988
  },
1983
1989
  getPendingEscalated() {
@@ -134,28 +134,30 @@ export function appendToTrailingAssistant(
134
134
  return [...prev.slice(0, -1), { ...last, segments: merged }]
135
135
  }
136
136
 
137
+ // =============================================================================
138
+ // Segment-projection kernel — shared scaffolding + single-rule predicates
139
+ // =============================================================================
140
+
137
141
  /**
138
- * Flip ONLY an approval_batch segment's status no execution writes.
139
- * The click-time optimistic flip uses this instead of
140
- * `projectApprovalResolutionToMessages` as defense-in-depth: even if a
141
- * (misbehaving) server emitted a batchId colliding with a row's
142
- * proposal id, the flip could not pre-tick that row's execution to a
143
- * false success before its confirm actually ran.
142
+ * Map every assistant message's segments through `fn`, preserving the
143
+ * REFERENTIAL-STABILITY CONTRACT in ONE place: `fn` returns the SAME
144
+ * segment reference to signal "unchanged"; untouched messages (and the
145
+ * whole array on a global no-op) keep their prior references. Every
146
+ * segment projection below rides this scaffold instead of re-implementing
147
+ * the changed-flag dance.
144
148
  */
145
- export function projectBatchStatusToMessages(
149
+ function mapSegments(
146
150
  prev: UnifiedChatMessage[],
147
- anchorId: string,
148
- status: ChatApprovalStatus,
151
+ fn: (s: MessageSegment) => MessageSegment,
149
152
  ): UnifiedChatMessage[] {
150
153
  let changed = false
151
154
  const next = prev.map((m) => {
152
155
  if (m.role !== 'assistant' || !m.segments) return m
153
156
  let msgChanged = false
154
157
  const segs = m.segments.map((s) => {
155
- if (s.type !== 'approval_batch') return s
156
- if (s.data.approvalRequestId !== anchorId || s.status === status) return s
157
- msgChanged = true
158
- return { ...s, status }
158
+ const out = fn(s)
159
+ if (out !== s) msgChanged = true
160
+ return out
159
161
  })
160
162
  if (!msgChanged) return m
161
163
  changed = true
@@ -164,6 +166,107 @@ export function projectBatchStatusToMessages(
164
166
  return changed ? next : prev
165
167
  }
166
168
 
169
+ /**
170
+ * THE approval-resolution rule for a single segment — one predicate for
171
+ * both containers (the message-array projections here AND the flat
172
+ * segment list in `MessageSegmentAccumulator.updateApprovalStatus`), so
173
+ * the two paths can never drift:
174
+ * - `approval_request` matched on `data.requestId` → status flip;
175
+ * - `approval_batch` anchor (`data.approvalRequestId`, the server's
176
+ * `batch:<first proposalId>` — never a row id) → status flip;
177
+ * - `approval_batch` ROW (`toolCalls[].toolExecutionRequestId`) →
178
+ * tick that row's execution (check on approved, cross otherwise)
179
+ * without touching the batch status.
180
+ * Returns the SAME reference when nothing matched or changed.
181
+ */
182
+ export function applyApprovalStatusToSegment(
183
+ s: MessageSegment,
184
+ requestId: string,
185
+ status: ChatApprovalStatus,
186
+ resolvedByName?: string | null,
187
+ ): MessageSegment {
188
+ if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
189
+ return { ...s, status }
190
+ }
191
+ if (s.type !== 'approval_batch') return s
192
+ const isAnchor = s.data.approvalRequestId === requestId
193
+ const hasRow = s.data.toolCalls?.some((c) => c.toolExecutionRequestId === requestId)
194
+ if (!isAnchor && !hasRow) return s
195
+ const nextResolvedBy = resolvedByName ?? s.resolvedByName
196
+ const nextExecutions = hasRow
197
+ ? {
198
+ ...(s.data.executions ?? {}),
199
+ [requestId]: { status: 'done' as const, success: status === 'approved' },
200
+ }
201
+ : s.data.executions
202
+ const nextStatus = isAnchor ? status : s.status
203
+ if (
204
+ s.status === nextStatus &&
205
+ nextResolvedBy === s.resolvedByName &&
206
+ nextExecutions === s.data.executions
207
+ ) {
208
+ return s
209
+ }
210
+ return {
211
+ ...s,
212
+ status: nextStatus,
213
+ resolvedByName: nextResolvedBy,
214
+ data: {
215
+ ...s.data,
216
+ ...(nextExecutions ? { executions: nextExecutions } : {}),
217
+ },
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Next state for a batch row's execution slot from a tool-execution
223
+ * chunk — folds the never-downgrade guard (redelivered EXECUTING after
224
+ * EXECUTED landed) and the EXECUTED/EXECUTING ternary into ONE rule
225
+ * shared by the message projection and the accumulator. Returns null
226
+ * for the no-op case (caller keeps prior references).
227
+ */
228
+ export function nextBatchExecution(
229
+ prevExec: ApprovalBatchExecutionState | undefined,
230
+ toolData: ToolExecutionData,
231
+ ): ApprovalBatchExecutionState | null {
232
+ if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') return null
233
+ return toolData.type === 'EXECUTED_TOOL'
234
+ ? { status: 'done', result: toolData.result, success: toolData.success }
235
+ : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
236
+ }
237
+
238
+ /** The executions-map triple-spread — one home for the write shape. */
239
+ export function withBatchExecution<S extends { data: { executions?: Record<string, ApprovalBatchExecutionState> } }>(
240
+ seg: S,
241
+ execId: string,
242
+ state: ApprovalBatchExecutionState,
243
+ ): S {
244
+ return {
245
+ ...seg,
246
+ data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: state } },
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Flip ONLY an approval_batch segment's status — no execution writes.
252
+ * The click-time optimistic flip uses this instead of
253
+ * `projectApprovalResolutionToMessages` as defense-in-depth: even if a
254
+ * (misbehaving) server emitted a batchId colliding with a row's
255
+ * proposal id, the flip could not pre-tick that row's execution to a
256
+ * false success before its confirm actually ran.
257
+ */
258
+ export function projectBatchStatusToMessages(
259
+ prev: UnifiedChatMessage[],
260
+ anchorId: string,
261
+ status: ChatApprovalStatus,
262
+ ): UnifiedChatMessage[] {
263
+ return mapSegments(prev, (s) => {
264
+ if (s.type !== 'approval_batch') return s
265
+ if (s.data.approvalRequestId !== anchorId || s.status === status) return s
266
+ return { ...s, status }
267
+ })
268
+ }
269
+
167
270
  /**
168
271
  * Mark ONE batch row's confirm as FAILED (expired proposal, network
169
272
  * error): tick its execution icon to the failure cross so the row's
@@ -174,36 +277,17 @@ export function projectBatchRowFailureToMessages(
174
277
  prev: UnifiedChatMessage[],
175
278
  rowRequestId: string,
176
279
  ): UnifiedChatMessage[] {
177
- let changed = false
178
- const next = prev.map((m) => {
179
- if (m.role !== 'assistant' || !m.segments) return m
180
- let msgChanged = false
181
- const segs = m.segments.map((s) => {
182
- if (
183
- s.type !== 'approval_batch' ||
184
- !s.data.toolCalls?.some((c) => c.toolExecutionRequestId === rowRequestId)
185
- ) {
186
- return s
187
- }
188
- const existing = s.data.executions?.[rowRequestId]
189
- if (existing?.status === 'done') return s
190
- msgChanged = true
191
- return {
192
- ...s,
193
- data: {
194
- ...s.data,
195
- executions: {
196
- ...(s.data.executions ?? {}),
197
- [rowRequestId]: { status: 'done' as const, success: false },
198
- },
199
- },
200
- }
201
- })
202
- if (!msgChanged) return m
203
- changed = true
204
- return { ...m, segments: segs }
280
+ return mapSegments(prev, (s) => {
281
+ if (
282
+ s.type !== 'approval_batch' ||
283
+ !s.data.toolCalls?.some((c) => c.toolExecutionRequestId === rowRequestId)
284
+ ) {
285
+ return s
286
+ }
287
+ const existing = s.data.executions?.[rowRequestId]
288
+ if (existing?.status === 'done') return s
289
+ return withBatchExecution(s, rowRequestId, { status: 'done', success: false })
205
290
  })
206
- return changed ? next : prev
207
291
  }
208
292
 
209
293
  /**
@@ -314,23 +398,16 @@ export function mergeToolExecutionIfPresent(
314
398
  seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
315
399
  ) {
316
400
  const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
317
- // Never downgrade a done batch slot back to executing (JetStream
318
- // redelivery of the EXECUTING chunk after EXECUTED landed). Returning
319
- // prev = matched-with-no-change, so the caller doesn't append either.
320
- if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
321
- return prev
322
- }
323
- const nextExec: ApprovalBatchExecutionState =
324
- toolData.type === 'EXECUTED_TOOL'
325
- ? { status: 'done', result: toolData.result, success: toolData.success }
326
- : { status: 'executing', result: prevExec?.result, success: prevExec?.success }
401
+ // Shared rule (`nextBatchExecution`): folds the never-downgrade
402
+ // guard (JetStream redelivery of EXECUTING after EXECUTED
403
+ // landed — null = matched-with-no-change, caller doesn't append
404
+ // either) and the EXECUTED/EXECUTING ternary.
405
+ const nextExec = nextBatchExecution(prevExec, toolData)
406
+ if (nextExec === null) return prev
327
407
  // Value-level no-op (replayed duplicate) → prior references.
328
408
  if (sameExecutionState(prevExec, nextExec)) return prev
329
409
  const nextSegments = [...message.segments]
330
- nextSegments[j] = {
331
- ...seg,
332
- data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
333
- }
410
+ nextSegments[j] = withBatchExecution(seg, execId, nextExec)
334
411
  const next = [...prev]
335
412
  next[i] = { ...message, segments: nextSegments }
336
413
  return next
@@ -376,10 +453,10 @@ export function mergeToolExecutionIfPresent(
376
453
  }
377
454
 
378
455
  /**
379
- * Cross-message approval status flip (absorbed from the NATS adapter's
380
- * `onApprovalResolved` handler). Idempotent + reference-preserving: only the
381
- * messages/segments whose status (or resolvedByName) actually changes are
382
- * cloned; a no-op returns `prev`.
456
+ * Project an approval resolution onto the thread the ONE rule lives in
457
+ * `applyApprovalStatusToSegment` (shared with the accumulator's flat
458
+ * segment list); this is just its message-array projection via
459
+ * `mapSegments`.
383
460
  */
384
461
  export function projectApprovalResolutionToMessages(
385
462
  prev: UnifiedChatMessage[],
@@ -387,60 +464,7 @@ export function projectApprovalResolutionToMessages(
387
464
  status: ChatApprovalStatus,
388
465
  resolvedByName?: string | null,
389
466
  ): UnifiedChatMessage[] {
390
- let changed = false
391
- const next = prev.map((m) => {
392
- if (m.role !== 'assistant' || !m.segments) return m
393
- let msgChanged = false
394
- const segs = m.segments.map((s) => {
395
- if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
396
- msgChanged = true
397
- return { ...s, status }
398
- }
399
- if (s.type === 'approval_batch') {
400
- const isAnchor = s.data.approvalRequestId === requestId
401
- // Wire-native chat batches: each ROW is its own proposal
402
- // (`toolExecutionRequestId` = proposalId) resolving through its
403
- // own per-proposal confirm — a resolution for a row ticks that
404
- // row's execution icon (check on approved, cross on rejected/
405
- // failed) without touching the batch status (flipped
406
- // optimistically at click time via
407
- // `projectBatchStatusToMessages`). The anchor id is the
408
- // server-minted `batch:<first proposalId>` — NEVER a row id
409
- // (see ApprovalBatchFrame.batchId) — so `isAnchor` flips the
410
- // batch status only.
411
- const hasRow = s.data.toolCalls?.some((c) => c.toolExecutionRequestId === requestId)
412
- if (!isAnchor && !hasRow) return s
413
- const nextResolvedBy = resolvedByName ?? s.resolvedByName
414
- const nextExecutions = hasRow
415
- ? {
416
- ...(s.data.executions ?? {}),
417
- [requestId]: { status: 'done' as const, success: status === 'approved' },
418
- }
419
- : s.data.executions
420
- const nextStatus = isAnchor ? status : s.status
421
- if (
422
- s.status === nextStatus &&
423
- nextResolvedBy === s.resolvedByName &&
424
- nextExecutions === s.data.executions
425
- ) {
426
- return s
427
- }
428
- msgChanged = true
429
- return {
430
- ...s,
431
- status: nextStatus,
432
- resolvedByName: nextResolvedBy,
433
- data: {
434
- ...s.data,
435
- ...(nextExecutions ? { executions: nextExecutions } : {}),
436
- },
437
- }
438
- }
439
- return s
440
- })
441
- if (!msgChanged) return m
442
- changed = true
443
- return { ...m, segments: segs }
444
- })
445
- return changed ? next : prev
467
+ return mapSegments(prev, (s) =>
468
+ applyApprovalStatusToSegment(s, requestId, status, resolvedByName),
469
+ )
446
470
  }
@@ -23,6 +23,11 @@ import type {
23
23
  ChatApprovalStatus,
24
24
  ExecutingToolState,
25
25
  } from '../types'
26
+ import {
27
+ applyApprovalStatusToSegment,
28
+ nextBatchExecution,
29
+ withBatchExecution,
30
+ } from '../stream/message-mutations'
26
31
 
27
32
  export interface AccumulatorCallbacks {
28
33
  /** See `ApprovalResolutionHandler` (message.types) — the SSOT for the
@@ -247,25 +252,13 @@ export class MessageSegmentAccumulator {
247
252
  if (!hasCall) return seg
248
253
 
249
254
  const prev: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
250
- // Never downgrade a done slot back to executing (redelivered EXECUTING
251
- // after its EXECUTED already landed) matched, but unchanged.
252
- if (toolData.type === 'EXECUTING_TOOL' && prev?.status === 'done') {
253
- matched = true
254
- return seg
255
- }
256
- const next: ApprovalBatchExecutionState =
257
- toolData.type === 'EXECUTED_TOOL'
258
- ? { status: 'done', result: toolData.result, success: toolData.success }
259
- : { status: 'executing', result: prev?.result, success: prev?.success }
260
-
255
+ // Shared rule (`nextBatchExecution` from message-mutations): the
256
+ // never-downgrade guard + EXECUTED/EXECUTING ternaryone
257
+ // implementation with the message-array projection.
258
+ const next = nextBatchExecution(prev, toolData)
261
259
  matched = true
262
- return {
263
- ...seg,
264
- data: {
265
- ...seg.data,
266
- executions: { ...(seg.data.executions ?? {}), [execId]: next },
267
- },
268
- }
260
+ if (next === null) return seg
261
+ return withBatchExecution(seg, execId, next)
269
262
  })
270
263
  return matched
271
264
  }
@@ -432,15 +425,14 @@ export class MessageSegmentAccumulator {
432
425
  * resolved card shows "by {name}"; omit it to leave any existing value untouched.
433
426
  */
434
427
  updateApprovalStatus(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): MessageSegment[] {
435
- this.segments = this.segments.map(segment => {
436
- if (segment.type === 'approval_request' && segment.data.requestId === requestId) {
437
- return { ...segment, status }
438
- }
439
- if (segment.type === 'approval_batch' && segment.data.approvalRequestId === requestId) {
440
- return { ...segment, status, resolvedByName: resolvedByName ?? segment.resolvedByName }
441
- }
442
- return segment
443
- })
428
+ // ONE rule, two containers: `applyApprovalStatusToSegment` is the
429
+ // same predicate the message-array projection uses
430
+ // (`projectApprovalResolutionToMessages`) anchor status flip AND
431
+ // batch-row execution tick, so this flat-list path can never drift
432
+ // from the projection again (it previously missed the row case).
433
+ this.segments = this.segments.map((segment) =>
434
+ applyApprovalStatusToSegment(segment, requestId, status, resolvedByName),
435
+ )
444
436
  return this.getSegments()
445
437
  }
446
438