@flamingo-stack/openframe-frontend-core 0.0.512-1640.4220.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 (107) hide show
  1. package/dist/{chunk-HCKICFSZ.cjs → chunk-2WWDZR4D.cjs} +31 -31
  2. package/dist/{chunk-HCKICFSZ.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
  3. package/dist/{chunk-FAPHWUEM.cjs → chunk-2XL7UK5Y.cjs} +5 -5
  4. package/dist/{chunk-FAPHWUEM.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
  5. package/dist/{chunk-XHGL7XHL.js → chunk-4NFDSMB5.js} +4 -4
  6. package/dist/{chunk-YJRJCJRK.cjs → chunk-5JBDT2MQ.cjs} +7 -7
  7. package/dist/{chunk-YJRJCJRK.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
  8. package/dist/{chunk-VL6RXIPW.cjs → chunk-67YDSTH7.cjs} +9 -9
  9. package/dist/{chunk-VL6RXIPW.cjs.map → chunk-67YDSTH7.cjs.map} +1 -1
  10. package/dist/{chunk-7UYW6C4P.js → chunk-6LMTBNLN.js} +2 -2
  11. package/dist/{chunk-QXFJU5I2.cjs → chunk-7VBEOGCR.cjs} +61 -61
  12. package/dist/{chunk-QXFJU5I2.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
  13. package/dist/{chunk-WOM5RZ6T.cjs → chunk-AWLFJMOJ.cjs} +37 -37
  14. package/dist/{chunk-WOM5RZ6T.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
  15. package/dist/{chunk-SIRV5BCG.cjs → chunk-DQDPDUI3.cjs} +4 -4
  16. package/dist/{chunk-SIRV5BCG.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
  17. package/dist/{chunk-Y5QFN4WH.cjs → chunk-F2YI5N7E.cjs} +13 -13
  18. package/dist/{chunk-Y5QFN4WH.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
  19. package/dist/{chunk-IYIO2OVS.js → chunk-FI35LJ3L.js} +2 -2
  20. package/dist/{chunk-NZR2U5VK.cjs → chunk-GOFTC2HV.cjs} +444 -479
  21. package/dist/chunk-GOFTC2HV.cjs.map +1 -0
  22. package/dist/{chunk-6SVI63CC.js → chunk-HGZPH7S5.js} +2 -2
  23. package/dist/{chunk-7LHDZK7M.js → chunk-IHNUMRMU.js} +2 -2
  24. package/dist/{chunk-YLVVZVK6.js → chunk-IK2WRYVA.js} +4 -4
  25. package/dist/{chunk-XDOZN745.cjs → chunk-JIW5JI3E.cjs} +3 -3
  26. package/dist/{chunk-XDOZN745.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
  27. package/dist/{chunk-CLEAYFOX.js → chunk-JKAX5K7C.js} +5 -5
  28. package/dist/{chunk-4BUHSV4D.js → chunk-JO3NPEK6.js} +2 -2
  29. package/dist/{chunk-ARTN2P3B.js → chunk-JOFGJYIT.js} +2 -2
  30. package/dist/{chunk-ZI3GAXFN.js → chunk-OT4RV6WY.js} +6 -6
  31. package/dist/{chunk-QUT2X2LX.cjs → chunk-RJXI25TJ.cjs} +26 -26
  32. package/dist/{chunk-QUT2X2LX.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
  33. package/dist/{chunk-EXDW3LB5.cjs → chunk-SG25J6LV.cjs} +87 -87
  34. package/dist/{chunk-EXDW3LB5.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
  35. package/dist/{chunk-DDWLGE6D.js → chunk-V5WLIFKN.js} +325 -360
  36. package/dist/chunk-V5WLIFKN.js.map +1 -0
  37. package/dist/{chunk-OOYBU46B.cjs → chunk-VVXUUBML.cjs} +26 -26
  38. package/dist/{chunk-OOYBU46B.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
  39. package/dist/{chunk-Q4XWNC33.js → chunk-VZX3SSFL.js} +2 -2
  40. package/dist/{chunk-WHKVCEAB.cjs → chunk-WVZHCDB5.cjs} +11 -11
  41. package/dist/{chunk-WHKVCEAB.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
  42. package/dist/{chunk-UMUNWABO.js → chunk-XG7FLAKQ.js} +2 -2
  43. package/dist/{chunk-EYUANCRA.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/hooks/use-chat-card-item.d.ts +3 -0
  48. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +2 -2
  50. package/dist/components/chat/index.js +1 -1
  51. package/dist/components/chat/remark-strip-citations.d.ts.map +1 -1
  52. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  53. package/dist/components/chat/stream/message-mutations.d.ts +42 -5
  54. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  55. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  56. package/dist/components/contact/index.cjs +3 -3
  57. package/dist/components/contact/index.js +2 -2
  58. package/dist/components/docs/index.cjs +5 -5
  59. package/dist/components/docs/index.js +4 -4
  60. package/dist/components/embeds/index.cjs +3 -3
  61. package/dist/components/embeds/index.js +2 -2
  62. package/dist/components/faq/index.cjs +3 -3
  63. package/dist/components/faq/index.js +2 -2
  64. package/dist/components/features/index.cjs +2 -2
  65. package/dist/components/features/index.js +1 -1
  66. package/dist/components/help-center-pages/index.cjs +22 -22
  67. package/dist/components/help-center-pages/index.js +13 -13
  68. package/dist/components/index.cjs +132 -132
  69. package/dist/components/index.js +11 -11
  70. package/dist/components/meeting-scheduler/index.cjs +34 -34
  71. package/dist/components/meeting-scheduler/index.js +3 -3
  72. package/dist/components/navigation/index.cjs +2 -2
  73. package/dist/components/navigation/index.js +1 -1
  74. package/dist/components/onboarding-guides/index.cjs +5 -5
  75. package/dist/components/onboarding-guides/index.js +4 -4
  76. package/dist/components/related-content/index.cjs +3 -3
  77. package/dist/components/related-content/index.js +2 -2
  78. package/dist/components/tickets/index.cjs +6 -6
  79. package/dist/components/tickets/index.js +5 -5
  80. package/dist/components/ui/index.cjs +2 -2
  81. package/dist/components/ui/index.js +1 -1
  82. package/dist/index.cjs +2 -2
  83. package/dist/index.js +1 -1
  84. package/package.json +1 -1
  85. package/src/components/chat/entity-cards/dispatch.tsx +92 -99
  86. package/src/components/chat/hooks/use-chat-card-item.ts +9 -0
  87. package/src/components/chat/hooks/use-sse-chat-adapter.ts +15 -15
  88. package/src/components/chat/remark-strip-citations.ts +7 -5
  89. package/src/components/chat/stream/__tests__/chat-stream-reducer-approval-batch.test.ts +18 -0
  90. package/src/components/chat/stream/chat-stream-reducer.ts +12 -3
  91. package/src/components/chat/stream/message-mutations.ts +153 -96
  92. package/src/components/chat/utils/message-segment-accumulator.ts +19 -27
  93. package/dist/chunk-DDWLGE6D.js.map +0 -1
  94. package/dist/chunk-NZR2U5VK.cjs.map +0 -1
  95. /package/dist/{chunk-XHGL7XHL.js.map → chunk-4NFDSMB5.js.map} +0 -0
  96. /package/dist/{chunk-7UYW6C4P.js.map → chunk-6LMTBNLN.js.map} +0 -0
  97. /package/dist/{chunk-IYIO2OVS.js.map → chunk-FI35LJ3L.js.map} +0 -0
  98. /package/dist/{chunk-6SVI63CC.js.map → chunk-HGZPH7S5.js.map} +0 -0
  99. /package/dist/{chunk-7LHDZK7M.js.map → chunk-IHNUMRMU.js.map} +0 -0
  100. /package/dist/{chunk-YLVVZVK6.js.map → chunk-IK2WRYVA.js.map} +0 -0
  101. /package/dist/{chunk-CLEAYFOX.js.map → chunk-JKAX5K7C.js.map} +0 -0
  102. /package/dist/{chunk-4BUHSV4D.js.map → chunk-JO3NPEK6.js.map} +0 -0
  103. /package/dist/{chunk-ARTN2P3B.js.map → chunk-JOFGJYIT.js.map} +0 -0
  104. /package/dist/{chunk-ZI3GAXFN.js.map → chunk-OT4RV6WY.js.map} +0 -0
  105. /package/dist/{chunk-Q4XWNC33.js.map → chunk-VZX3SSFL.js.map} +0 -0
  106. /package/dist/{chunk-UMUNWABO.js.map → chunk-XG7FLAKQ.js.map} +0 -0
  107. /package/dist/{chunk-EYUANCRA.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> = {
@@ -1558,7 +1554,7 @@ export function ChatCardLoader({
1558
1554
  // `contentRefType` is empty so the hook returns `isLoading=false` and
1559
1555
  // `item=undefined`, which we ignore.
1560
1556
  const fetchEntry = entry && entry.mode === 'fetch' ? entry : null
1561
- const { item, isLoading, isError } = useChatCardItem<any>(
1557
+ const { item, isLoading, isError, isFetched } = useChatCardItem<any>(
1562
1558
  fetchEntry?.contentRefType ?? '',
1563
1559
  fetchEntry ? resolvedChatRef.id : '',
1564
1560
  )
@@ -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) {
@@ -1711,6 +1712,10 @@ export function ChatCardLoader({
1711
1712
  // "deleted" claim. Only a SUCCESSFUL fetch that lacks the id
1712
1713
  // reaches the tombstone below.
1713
1714
  if (isError) return null
1715
+ // NEVER-FETCHED (query disabled — no list URL registered for this
1716
+ // type, or an empty id): no request was made, so absence of `item`
1717
+ // proves nothing. Render nothing rather than a false "deleted".
1718
+ if (!isFetched) return null
1714
1719
  // FETCH MISS. Two distinct cases, split by ref provenance:
1715
1720
  //
1716
1721
  // - SERVER-BUILT ref (`sourceRepo` present — the entity provably
@@ -1738,19 +1743,7 @@ export function ChatCardLoader({
1738
1743
  }
1739
1744
  return null
1740
1745
  }
1741
- if (entry.bareInline) {
1742
- return <>{navWrap(entry.render(item, finalChatRef, renderOpts))}</>
1743
- }
1744
- return (
1745
- <ChatCardWithDiscuss
1746
- chatRef={finalChatRef}
1747
- onDiscuss={onDiscuss}
1748
- onDisplay={onDisplay}
1749
- displayAction={entry.displayAction}
1750
- >
1751
- {navWrap(entry.render(item, finalChatRef, renderOpts))}
1752
- </ChatCardWithDiscuss>
1753
- )
1746
+ return finish(entry.render(item, finalChatRef, renderOpts))
1754
1747
  }
1755
1748
 
1756
1749
  // =============================================================================
@@ -28,6 +28,9 @@ export interface UseChatCardItemResult<T = unknown> {
28
28
  item: T | undefined
29
29
  isLoading: boolean
30
30
  isError: boolean
31
+ /** True only after a fetch actually completed — false for disabled
32
+ * queries (no list URL for the type / empty id). */
33
+ isFetched: boolean
31
34
  }
32
35
 
33
36
  // `extractItems` / `extractItemId` hoisted to `src/utils/extract-items.ts`
@@ -84,5 +87,11 @@ export function useChatCardItem<T = unknown>(
84
87
  item: (query.data ?? undefined) as T | undefined,
85
88
  isLoading: query.isLoading,
86
89
  isError: query.isError,
90
+ // True only after the query actually completed a fetch. DISABLED
91
+ // queries (no list URL registered for the type, empty id) never
92
+ // fetch — `item` is undefined there as well, and callers must not
93
+ // read that as "fetched fine, entity absent" (tombstone gate in
94
+ // entity-cards/dispatch.tsx).
95
+ isFetched: query.isFetched,
87
96
  }
88
97
  }
@@ -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 => ({
@@ -27,11 +27,13 @@ export const remarkStripCitations: Plugin<[], Root> = () => {
27
27
  return (tree: Root) => {
28
28
  visit(tree, "text", (node: Text) => {
29
29
  if (!node.value || !node.value.includes("[")) return
30
- const stripped = node.value
31
- .replace(CITATION_REGEX, "$1")
32
- // Collapse doubled spaces the removal can leave mid-sentence.
33
- .replace(/ {2,}/g, " ")
34
- if (stripped !== node.value) node.value = stripped
30
+ const withoutCitations = node.value.replace(CITATION_REGEX, "$1")
31
+ // No citation matched → leave the node untouched, INCLUDING its
32
+ // whitespace (collapsing unconditionally would rewrite deliberate
33
+ // spacing in prose like `array[1] value`).
34
+ if (withoutCitations === node.value) return
35
+ // Collapse doubled spaces the removal can leave mid-sentence.
36
+ node.value = withoutCitations.replace(/ {2,}/g, " ")
35
37
  })
36
38
  }
37
39
  }
@@ -175,4 +175,22 @@ describe('decoder — approval_batch frame mapping', () => {
175
175
  expect(approval!.toolCalls?.[0].toolTitle).toContain('Fix Website Header')
176
176
  expect(approval!.toolCalls?.[1].toolExecutionRequestId).toBe('prop-2')
177
177
  })
178
+
179
+ it('a colliding anchor id (misbehaving server) still cannot pre-tick a row execution', async () => {
180
+ // Defense-in-depth pin: the click-time flip uses the STATUS-ONLY
181
+ // projection, so even a server that (incorrectly) reuses a row's
182
+ // proposal id as the batchId cannot mark that row done before its
183
+ // confirm actually resolves.
184
+ const onApprove = vi.fn().mockResolvedValue(undefined)
185
+ const r = createChatStreamReducer({ transport: 'sse', callbacks: { onApprove } })
186
+ r.beginSseSend({ text: 'delete both', assistantName: 'Mingo AI' })
187
+ r.apply({ ...batchEvent, requestId: 'prop-1' })
188
+ const batch = findBatch(r)
189
+ await batch.onApprove?.(batch.data.approvalRequestId)
190
+ const after = findBatch(r)
191
+ expect(after.status).toBe('approved')
192
+ // The flip itself wrote NO execution for prop-1 — its icon state
193
+ // comes only from the confirm result / approval-resolved event.
194
+ expect(after.data.executions ?? {}).toEqual({})
195
+ })
178
196
  })
@@ -95,6 +95,7 @@ import {
95
95
  nextId,
96
96
  projectApprovalResolutionToMessages,
97
97
  projectBatchRowFailureToMessages,
98
+ projectBatchStatusToMessages,
98
99
  updateTrailingAssistant,
99
100
  upsertTrailingCompaction,
100
101
  } from './message-mutations'
@@ -1562,9 +1563,11 @@ export function createChatStreamReducer(
1562
1563
  const resolveBatch =
1563
1564
  (status: 'approved' | 'rejected', confirm: typeof callbacks.onApprove) =>
1564
1565
  async () => {
1565
- setMessagesInternal(
1566
- projectApprovalResolutionToMessages(messages, anchorId, status),
1567
- )
1566
+ // STATUS-ONLY projection (defense-in-depth): even a
1567
+ // batchId colliding with a row id cannot pre-tick an
1568
+ // execution here — rows resolve exclusively via their
1569
+ // own confirm results / approval-resolved events.
1570
+ setMessagesInternal(projectBatchStatusToMessages(messages, anchorId, status))
1568
1571
  for (const id of ids) {
1569
1572
  const ok = await confirm?.(id)
1570
1573
  if (ok === false) {
@@ -1975,6 +1978,12 @@ export function createChatStreamReducer(
1975
1978
  setMessagesInternal(
1976
1979
  projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName),
1977
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)
1978
1987
  return segments
1979
1988
  },
1980
1989
  getPendingEscalated() {