@flamingo-stack/openframe-frontend-core 0.0.579-1952.5335.1 → 0.0.579

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 (236) hide show
  1. package/dist/chat-protocol/decode.d.ts.map +1 -1
  2. package/dist/chat-protocol/events.d.ts +47 -1
  3. package/dist/chat-protocol/events.d.ts.map +1 -1
  4. package/dist/chat-protocol/index.cjs +51 -0
  5. package/dist/chat-protocol/index.cjs.map +1 -1
  6. package/dist/chat-protocol/index.js +48 -1
  7. package/dist/chat-protocol/index.js.map +1 -1
  8. package/dist/chat-protocol/leading-frames.d.ts +9 -3
  9. package/dist/chat-protocol/leading-frames.d.ts.map +1 -1
  10. package/dist/chat-protocol/nats-decoder.d.ts +41 -0
  11. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  12. package/dist/{chunk-3B42TFEE.cjs → chunk-36TJYCF3.cjs} +27 -27
  13. package/dist/{chunk-3B42TFEE.cjs.map → chunk-36TJYCF3.cjs.map} +1 -1
  14. package/dist/{chunk-YYEVFC3K.cjs → chunk-37XJYOAY.cjs} +17 -17
  15. package/dist/{chunk-YYEVFC3K.cjs.map → chunk-37XJYOAY.cjs.map} +1 -1
  16. package/dist/{chunk-DBP7S75Q.js → chunk-3AX3ZEY3.js} +1481 -1318
  17. package/dist/chunk-3AX3ZEY3.js.map +1 -0
  18. package/dist/{chunk-DGB2FLFH.cjs → chunk-4FJSN2XW.cjs} +10 -10
  19. package/dist/{chunk-DGB2FLFH.cjs.map → chunk-4FJSN2XW.cjs.map} +1 -1
  20. package/dist/{chunk-WHSSI3S7.cjs → chunk-4HGRXFQP.cjs} +3 -3
  21. package/dist/{chunk-WHSSI3S7.cjs.map → chunk-4HGRXFQP.cjs.map} +1 -1
  22. package/dist/{chunk-SY5HCZZO.cjs → chunk-4LZ7B5H5.cjs} +5 -5
  23. package/dist/{chunk-SY5HCZZO.cjs.map → chunk-4LZ7B5H5.cjs.map} +1 -1
  24. package/dist/{chunk-XJ45MHWI.js → chunk-4WSKSNXL.js} +9 -9
  25. package/dist/{chunk-ZCEQLHU3.js → chunk-5Y6TPFW7.js} +5 -5
  26. package/dist/{chunk-EVTRRDIH.cjs → chunk-A6C5HOD7.cjs} +23 -23
  27. package/dist/{chunk-EVTRRDIH.cjs.map → chunk-A6C5HOD7.cjs.map} +1 -1
  28. package/dist/{chunk-RXZWUJEB.js → chunk-AKPLEWMH.js} +2 -2
  29. package/dist/{chunk-7WKFLOC7.cjs → chunk-BCJQ7CJE.cjs} +11 -11
  30. package/dist/{chunk-7WKFLOC7.cjs.map → chunk-BCJQ7CJE.cjs.map} +1 -1
  31. package/dist/{chunk-C4KL7OQI.js → chunk-BW4WDUSI.js} +3 -3
  32. package/dist/{chunk-L66XBQM6.js → chunk-CP5EYMNL.js} +276 -160
  33. package/dist/chunk-CP5EYMNL.js.map +1 -0
  34. package/dist/{chunk-QOPQ6BCA.js → chunk-D44S2QLI.js} +3 -3
  35. package/dist/{chunk-CGVPUA4X.js → chunk-DB3TV4CW.js} +6 -6
  36. package/dist/{chunk-PITZMTGE.cjs → chunk-FEVLXRTC.cjs} +506 -390
  37. package/dist/chunk-FEVLXRTC.cjs.map +1 -0
  38. package/dist/{chunk-TYJITV3H.js → chunk-H4BPHGU5.js} +3 -3
  39. package/dist/{chunk-C7Q2PVHD.cjs → chunk-H54EPP3P.cjs} +7 -7
  40. package/dist/{chunk-C7Q2PVHD.cjs.map → chunk-H54EPP3P.cjs.map} +1 -1
  41. package/dist/{chunk-NWAUHBYH.js → chunk-HGA74X35.js} +4 -4
  42. package/dist/{chunk-4C3MS2G6.cjs → chunk-HHJVIHVR.cjs} +81 -81
  43. package/dist/{chunk-4C3MS2G6.cjs.map → chunk-HHJVIHVR.cjs.map} +1 -1
  44. package/dist/{chunk-3XFMM34I.cjs → chunk-IFYQ7DHV.cjs} +48 -48
  45. package/dist/{chunk-3XFMM34I.cjs.map → chunk-IFYQ7DHV.cjs.map} +1 -1
  46. package/dist/{chunk-QYQXAXVR.cjs → chunk-IKDC3R7V.cjs} +4 -3
  47. package/dist/chunk-IKDC3R7V.cjs.map +1 -0
  48. package/dist/{chunk-W6JA6SFD.js → chunk-JRYS4VMV.js} +45 -45
  49. package/dist/chunk-JRYS4VMV.js.map +1 -0
  50. package/dist/{chunk-AT4WAFJO.js → chunk-L2CF2QZW.js} +7 -7
  51. package/dist/{chunk-2BFZHTSD.cjs → chunk-LEISUAHF.cjs} +18 -8
  52. package/dist/chunk-LEISUAHF.cjs.map +1 -0
  53. package/dist/{chunk-AAHKPPGQ.cjs → chunk-M5GWCMYE.cjs} +42 -42
  54. package/dist/{chunk-AAHKPPGQ.cjs.map → chunk-M5GWCMYE.cjs.map} +1 -1
  55. package/dist/{chunk-PQETCTJ4.js → chunk-MXOSTJAG.js} +4 -3
  56. package/dist/chunk-MXOSTJAG.js.map +1 -0
  57. package/dist/{chunk-653A7FPP.cjs → chunk-NPVDKOGD.cjs} +22 -22
  58. package/dist/{chunk-653A7FPP.cjs.map → chunk-NPVDKOGD.cjs.map} +1 -1
  59. package/dist/{chunk-2ML5QEDH.js → chunk-O2FTVZOD.js} +5 -5
  60. package/dist/{chunk-OCSAB6NT.cjs → chunk-OGZAFM6M.cjs} +5 -5
  61. package/dist/{chunk-OCSAB6NT.cjs.map → chunk-OGZAFM6M.cjs.map} +1 -1
  62. package/dist/{chunk-CSBNQJSI.js → chunk-PMENIZXI.js} +6 -6
  63. package/dist/{chunk-PFTEG6L7.js → chunk-PWOYEUIQ.js} +7 -7
  64. package/dist/{chunk-KXLJT5U5.js → chunk-QCP2KV7P.js} +5 -5
  65. package/dist/{chunk-6QDW4CDG.js → chunk-QGJYWLF4.js} +2 -2
  66. package/dist/{chunk-VLRXLTR4.cjs → chunk-RKGOVTXZ.cjs} +377 -214
  67. package/dist/chunk-RKGOVTXZ.cjs.map +1 -0
  68. package/dist/{chunk-HM2R5MDR.js → chunk-T5HDPHN6.js} +13 -3
  69. package/dist/chunk-T5HDPHN6.js.map +1 -0
  70. package/dist/{chunk-GP2QLZIN.cjs → chunk-YW4WBNYV.cjs} +37 -37
  71. package/dist/{chunk-GP2QLZIN.cjs.map → chunk-YW4WBNYV.cjs.map} +1 -1
  72. package/dist/{chunk-36NFJULV.cjs → chunk-ZI27ALYX.cjs} +41 -41
  73. package/dist/chunk-ZI27ALYX.cjs.map +1 -0
  74. package/dist/components/case-studies/index.cjs +12 -12
  75. package/dist/components/case-studies/index.js +6 -6
  76. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  77. package/dist/components/chat/guide-display.d.ts +14 -0
  78. package/dist/components/chat/guide-display.d.ts.map +1 -0
  79. package/dist/components/chat/index.cjs +11 -3
  80. package/dist/components/chat/index.cjs.map +1 -1
  81. package/dist/components/chat/index.d.ts +1 -0
  82. package/dist/components/chat/index.d.ts.map +1 -1
  83. package/dist/components/chat/index.js +10 -2
  84. package/dist/components/chat/stream/chat-stream-reducer.d.ts +9 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  86. package/dist/components/chat/stream/delta-batcher.d.ts +3 -3
  87. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -1
  88. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  89. package/dist/components/chat/types/component.types.d.ts +6 -0
  90. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  91. package/dist/components/chat/types/message.types.d.ts +30 -2
  92. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  93. package/dist/components/chat/types/network.types.d.ts +3 -1
  94. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  95. package/dist/components/chat/types/processing.types.d.ts +4 -1
  96. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  97. package/dist/components/chat/utils/approval-display.d.ts +36 -8
  98. package/dist/components/chat/utils/approval-display.d.ts.map +1 -1
  99. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  100. package/dist/components/chat/utils/index.d.ts +1 -1
  101. package/dist/components/chat/utils/index.d.ts.map +1 -1
  102. package/dist/components/chat/utils/message-segment-accumulator.d.ts +16 -4
  103. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  104. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  105. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +1 -1
  106. package/dist/components/contact/index.cjs +7 -7
  107. package/dist/components/contact/index.js +6 -6
  108. package/dist/components/docs/index.cjs +10 -10
  109. package/dist/components/docs/index.js +9 -9
  110. package/dist/components/embeds/index.cjs +7 -7
  111. package/dist/components/embeds/index.js +6 -6
  112. package/dist/components/faq/index.cjs +7 -7
  113. package/dist/components/faq/index.js +6 -6
  114. package/dist/components/features/board/board.d.ts.map +1 -1
  115. package/dist/components/features/index.cjs +8 -6
  116. package/dist/components/features/index.cjs.map +1 -1
  117. package/dist/components/features/index.js +7 -5
  118. package/dist/components/features/sortable-list/index.d.ts +2 -1
  119. package/dist/components/features/sortable-list/index.d.ts.map +1 -1
  120. package/dist/components/features/sortable-list/sortable-list.d.ts +8 -1
  121. package/dist/components/features/sortable-list/sortable-list.d.ts.map +1 -1
  122. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts +24 -0
  123. package/dist/components/features/sortable-list/sortable-move-buttons.d.ts.map +1 -0
  124. package/dist/components/features/sortable-list/use-sortable-item.d.ts +12 -4
  125. package/dist/components/features/sortable-list/use-sortable-item.d.ts.map +1 -1
  126. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts +21 -5
  127. package/dist/components/features/ticket-status-config-list/ticket-status-config-list.d.ts.map +1 -1
  128. package/dist/components/help-center-pages/index.cjs +26 -26
  129. package/dist/components/help-center-pages/index.js +17 -17
  130. package/dist/components/index.cjs +139 -129
  131. package/dist/components/index.cjs.map +1 -1
  132. package/dist/components/index.js +26 -16
  133. package/dist/components/index.js.map +1 -1
  134. package/dist/components/meeting-scheduler/index.cjs +37 -37
  135. package/dist/components/meeting-scheduler/index.js +5 -5
  136. package/dist/components/navigation/index.cjs +9 -9
  137. package/dist/components/navigation/index.js +8 -8
  138. package/dist/components/onboarding-guides/index.cjs +9 -9
  139. package/dist/components/onboarding-guides/index.js +8 -8
  140. package/dist/components/tickets/index.cjs +10 -10
  141. package/dist/components/tickets/index.js +9 -9
  142. package/dist/components/ui/file-manager/index.cjs +4 -4
  143. package/dist/components/ui/file-manager/index.js +1 -1
  144. package/dist/components/ui/index.cjs +14 -6
  145. package/dist/components/ui/index.cjs.map +1 -1
  146. package/dist/components/ui/index.js +13 -5
  147. package/dist/components/ui/input.d.ts +2 -0
  148. package/dist/components/ui/input.d.ts.map +1 -1
  149. package/dist/components/ui/textarea.d.ts +2 -0
  150. package/dist/components/ui/textarea.d.ts.map +1 -1
  151. package/dist/components/ui/ticket-status-config-row.d.ts +10 -4
  152. package/dist/components/ui/ticket-status-config-row.d.ts.map +1 -1
  153. package/dist/hooks/index.cjs +7 -3
  154. package/dist/hooks/index.cjs.map +1 -1
  155. package/dist/hooks/index.js +6 -2
  156. package/dist/hooks/ui/index.d.ts +1 -0
  157. package/dist/hooks/ui/index.d.ts.map +1 -1
  158. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts +31 -0
  159. package/dist/hooks/ui/use-drag-and-drop-enabled.d.ts.map +1 -0
  160. package/dist/hooks/ui/use-media-query.d.ts.map +1 -1
  161. package/dist/index.cjs +25 -11
  162. package/dist/index.cjs.map +1 -1
  163. package/dist/index.js +23 -9
  164. package/dist/index.js.map +1 -1
  165. package/dist/utils/index.cjs.map +1 -1
  166. package/dist/utils/index.js.map +1 -1
  167. package/package.json +1 -4
  168. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +80 -0
  169. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +118 -1
  170. package/src/chat-protocol/decode.ts +4 -3
  171. package/src/chat-protocol/events.ts +51 -0
  172. package/src/chat-protocol/leading-frames.ts +9 -3
  173. package/src/chat-protocol/nats-decoder.ts +110 -1
  174. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +19 -0
  175. package/src/components/chat/chat-message-enhanced.tsx +13 -1
  176. package/src/components/chat/guide-display.tsx +42 -0
  177. package/src/components/chat/index.ts +1 -0
  178. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +90 -0
  179. package/src/components/chat/stream/chat-dialog-store.ts +1 -1
  180. package/src/components/chat/stream/chat-stream-reducer.ts +49 -11
  181. package/src/components/chat/stream/delta-batcher.ts +9 -4
  182. package/src/components/chat/stream/message-mutations.ts +2 -0
  183. package/src/components/chat/types/component.types.ts +9 -0
  184. package/src/components/chat/types/message.types.ts +32 -0
  185. package/src/components/chat/types/network.types.ts +3 -1
  186. package/src/components/chat/types/processing.types.ts +4 -1
  187. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +93 -0
  188. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +78 -1
  189. package/src/components/chat/utils/approval-display.ts +53 -8
  190. package/src/components/chat/utils/extract-incomplete-message-state.ts +1 -0
  191. package/src/components/chat/utils/index.ts +6 -1
  192. package/src/components/chat/utils/message-segment-accumulator.ts +37 -3
  193. package/src/components/chat/utils/process-historical-messages.ts +44 -7
  194. package/src/components/chat/utils/resolve-fetched-card-href.ts +1 -1
  195. package/src/components/features/board/.board.md +1 -0
  196. package/src/components/features/board/board.tsx +11 -1
  197. package/src/components/features/sortable-list/.sortable-list.md +23 -12
  198. package/src/components/features/sortable-list/index.ts +2 -1
  199. package/src/components/features/sortable-list/sortable-list.tsx +16 -5
  200. package/src/components/features/sortable-list/sortable-move-buttons.tsx +81 -0
  201. package/src/components/features/sortable-list/use-sortable-item.ts +25 -13
  202. package/src/components/features/ticket-status-config-list/ticket-status-config-list.tsx +55 -62
  203. package/src/components/ui/.ticket-status-config-row.md +11 -9
  204. package/src/components/ui/input.tsx +5 -2
  205. package/src/components/ui/textarea.tsx +5 -2
  206. package/src/components/ui/ticket-status-config-row.tsx +162 -70
  207. package/src/hooks/ui/.index.md +1 -0
  208. package/src/hooks/ui/.use-drag-and-drop-enabled.md +32 -0
  209. package/src/hooks/ui/index.ts +1 -0
  210. package/src/hooks/ui/use-drag-and-drop-enabled.ts +37 -0
  211. package/src/hooks/ui/use-media-query.ts +3 -2
  212. package/src/stories/ChatMessageEnhanced.stories.tsx +7 -3
  213. package/dist/chunk-2BFZHTSD.cjs.map +0 -1
  214. package/dist/chunk-36NFJULV.cjs.map +0 -1
  215. package/dist/chunk-DBP7S75Q.js.map +0 -1
  216. package/dist/chunk-HM2R5MDR.js.map +0 -1
  217. package/dist/chunk-L66XBQM6.js.map +0 -1
  218. package/dist/chunk-PITZMTGE.cjs.map +0 -1
  219. package/dist/chunk-PQETCTJ4.js.map +0 -1
  220. package/dist/chunk-QYQXAXVR.cjs.map +0 -1
  221. package/dist/chunk-VLRXLTR4.cjs.map +0 -1
  222. package/dist/chunk-W6JA6SFD.js.map +0 -1
  223. /package/dist/{chunk-XJ45MHWI.js.map → chunk-4WSKSNXL.js.map} +0 -0
  224. /package/dist/{chunk-ZCEQLHU3.js.map → chunk-5Y6TPFW7.js.map} +0 -0
  225. /package/dist/{chunk-RXZWUJEB.js.map → chunk-AKPLEWMH.js.map} +0 -0
  226. /package/dist/{chunk-C4KL7OQI.js.map → chunk-BW4WDUSI.js.map} +0 -0
  227. /package/dist/{chunk-QOPQ6BCA.js.map → chunk-D44S2QLI.js.map} +0 -0
  228. /package/dist/{chunk-CGVPUA4X.js.map → chunk-DB3TV4CW.js.map} +0 -0
  229. /package/dist/{chunk-TYJITV3H.js.map → chunk-H4BPHGU5.js.map} +0 -0
  230. /package/dist/{chunk-NWAUHBYH.js.map → chunk-HGA74X35.js.map} +0 -0
  231. /package/dist/{chunk-AT4WAFJO.js.map → chunk-L2CF2QZW.js.map} +0 -0
  232. /package/dist/{chunk-2ML5QEDH.js.map → chunk-O2FTVZOD.js.map} +0 -0
  233. /package/dist/{chunk-CSBNQJSI.js.map → chunk-PMENIZXI.js.map} +0 -0
  234. /package/dist/{chunk-PFTEG6L7.js.map → chunk-PWOYEUIQ.js.map} +0 -0
  235. /package/dist/{chunk-KXLJT5U5.js.map → chunk-QCP2KV7P.js.map} +0 -0
  236. /package/dist/{chunk-6QDW4CDG.js.map → chunk-QGJYWLF4.js.map} +0 -0
@@ -4,20 +4,65 @@
4
4
  *
5
5
  * There are three such paths and they must agree: the NATS kernel (live chunks),
6
6
  * the SSE kernel, and the history replay that rebuilds a dialog after a reload.
7
- * When the rule lived only in the live kernel, a card could render inline while
8
- * streaming and then move into the escalation treatment on the next page load —
9
- * same card, two behaviors.
7
+ * When the rule lived only in the live kernel, a Product Guide card rendered
8
+ * inline while streaming and then moved into the escalation treatment on the
9
+ * next page load — same card, two behaviors, and its buttons pointed at the
10
+ * wrong backend once it moved.
11
+ *
12
+ * The rule itself: a card is gated on approval TIER (`displayApprovalTypes`,
13
+ * the agent's own privilege model) UNLESS it came from the Product Guide, whose
14
+ * proposals are typed by TOOL and are resolved through the hub's confirm route.
15
+ * A hub proposal has no tier to gate on, so tier-gating one means escalating it
16
+ * to a human who has no way to act on it.
10
17
  */
11
18
 
19
+ import type { GuideOrigin } from '../../../chat-protocol/events';
20
+ import { GUIDE_ORIGIN, isGuideOrigin } from '../../../chat-protocol/events';
21
+ import type { MessageSegment } from '../types/message.types';
22
+
23
+ /** Anything carrying the optional `origin` marker: a stream event, or the
24
+ * `data` of a rendered segment. */
25
+ interface OriginBearing {
26
+ origin?: GuideOrigin | string;
27
+ }
28
+
29
+ /** True when this approval was minted by the Product Guide (the hub) rather
30
+ * than by the agent's own tool-approval tiering. */
31
+ export function isGuideApproval(source: OriginBearing | null | undefined): boolean {
32
+ return isGuideOrigin(source);
33
+ }
34
+
35
+ /** The marker to stamp onto a built segment, or undefined for agent approvals.
36
+ * Keeps `origin` out of segment data unless it means something. */
37
+ export function guideApprovalOrigin(source: OriginBearing | null | undefined): GuideOrigin | undefined {
38
+ return isGuideApproval(source) ? GUIDE_ORIGIN : undefined;
39
+ }
40
+
12
41
  /**
13
42
  * Does this approval render inline in its turn (true), or take the consumer's
14
43
  * escalation treatment (false)?
15
44
  *
16
- * A card is gated on approval TIER (`displayApprovalTypes`, the agent's own
17
- * privilege model). `undefined` means "no tier gate configured" and every
18
- * approval displays — the pre-existing history-replay default, preserved here
19
- * so both kernels can share one predicate.
45
+ * `displayApprovalTypes` undefined means "no tier gate configured" and every
46
+ * agent approval displays — the pre-existing history-replay default, preserved
47
+ * here so both kernels can share one predicate.
20
48
  */
21
- export function approvalDisplaysInline(approvalType: string, displayApprovalTypes?: readonly string[]): boolean {
49
+ export function approvalDisplaysInline(
50
+ source: OriginBearing | null | undefined,
51
+ approvalType: string,
52
+ displayApprovalTypes?: readonly string[],
53
+ ): boolean {
54
+ if (isGuideApproval(source)) return true;
22
55
  return !displayApprovalTypes || displayApprovalTypes.includes(approvalType);
23
56
  }
57
+
58
+ /**
59
+ * True for a rendered segment that holds a Product Guide card (single or batch).
60
+ *
61
+ * Consumers use it to keep such a card in the message flow, where the hub's own
62
+ * chat puts it, instead of lifting it into their sticky pending-approvals
63
+ * footer — and to route its buttons to the hub rather than to the agent.
64
+ */
65
+ export function isGuideApprovalSegment(segment: MessageSegment): boolean {
66
+ if (segment.type !== 'approval_request' && segment.type !== 'approval_batch') return false;
67
+ return isGuideApproval(segment.data);
68
+ }
@@ -136,6 +136,7 @@ export function extractIncompleteMessageState(
136
136
  // backend has already abandoned.
137
137
  case 'text':
138
138
  case 'thinking':
139
+ case 'guide':
139
140
  case 'ask':
140
141
  case 'ticket_escalated':
141
142
  case 'ticket_event':
@@ -16,7 +16,12 @@ export {
16
16
 
17
17
  // Where an approval card renders — one rule for both live kernels, the history
18
18
  // replay, and hosts that sort cards between their own footer and the flow.
19
- export { approvalDisplaysInline } from './approval-display';
19
+ export {
20
+ isGuideApproval,
21
+ guideApprovalOrigin,
22
+ approvalDisplaysInline,
23
+ isGuideApprovalSegment,
24
+ } from './approval-display';
20
25
 
21
26
  // Historical message processing
22
27
  export {
@@ -155,6 +155,22 @@ export class MessageSegmentAccumulator {
155
155
  return this.getSegments();
156
156
  }
157
157
 
158
+ /**
159
+ * Append guide text to the current message.
160
+ * If the last segment is guide, append to it; otherwise start a new guide segment.
161
+ */
162
+ appendGuide(text: string): MessageSegment[] {
163
+ const lastSegment = this.segments[this.segments.length - 1];
164
+
165
+ if (lastSegment && lastSegment.type === 'guide') {
166
+ this.segments[this.segments.length - 1] = { type: 'guide', text: lastSegment.text + text };
167
+ } else {
168
+ this.segments.push({ type: 'guide', text });
169
+ }
170
+
171
+ return this.getSegments();
172
+ }
173
+
158
174
  /**
159
175
  * Add a clarification (ask) card. Unlike the three delta streams an ask
160
176
  * arrives whole in one chunk, so it is always a NEW segment — never merged
@@ -309,11 +325,15 @@ export class MessageSegmentAccumulator {
309
325
  status: ChatApprovalStatus = 'pending',
310
326
  /** Structured label/value rows. The card prefers them over `explanation`
311
327
  * (see `ApprovalRequestData.fields`). Optional because the agent's own
312
- * approvals carry prose. */
328
+ * approvals carry prose; a Product Guide card is almost entirely fields —
329
+ * dropping them here left it as a bare title. */
313
330
  fields?: ApprovalRequestField[],
331
+ /** Where the card came from; `'guide'` keeps it inline (see
332
+ * `ApprovalRequestData.origin`). */
333
+ origin?: 'guide',
314
334
  ): MessageSegment[] {
315
335
  this.segments.push(
316
- this.buildApprovalRequestSegment(requestId, { command, explanation, approvalType, fields }, status),
336
+ this.buildApprovalRequestSegment(requestId, { command, explanation, approvalType, fields, origin }, status),
317
337
  );
318
338
  return this.getSegments();
319
339
  }
@@ -324,7 +344,7 @@ export class MessageSegmentAccumulator {
324
344
  * Three paths produce these: `addApprovalRequest` (live stream and replay),
325
345
  * `flushPendingApprovals` (tracked-but-unresolved after a history replay) and
326
346
  * `processApprovalResult` (a result arriving for a tracked request). They used
327
- * to hand-write the object each, which is how `fields` reached
347
+ * to hand-write the object each, which is how `fields` and `origin` reached
328
348
  * the card down one path and not the others — the same proposal rendering as a
329
349
  * bare title, or losing the marker that routes its buttons to the hub.
330
350
  */
@@ -341,6 +361,7 @@ export class MessageSegmentAccumulator {
341
361
  requestId,
342
362
  approvalType: approval.approvalType,
343
363
  ...(approval.fields && approval.fields.length > 0 ? { fields: approval.fields } : {}),
364
+ ...(approval.origin ? { origin: approval.origin } : {}),
344
365
  },
345
366
  status,
346
367
  onApprove: this.callbacks.onApprove,
@@ -368,6 +389,9 @@ export class MessageSegmentAccumulator {
368
389
  status: ChatApprovalStatus = 'pending',
369
390
  executions?: Record<string, ApprovalBatchExecutionState>,
370
391
  resolvedByName?: string | null,
392
+ /** Where the batch came from; `'guide'` keeps it inline and routes it to
393
+ * the hub, exactly as for a single card (see `ApprovalBatchData.origin`). */
394
+ origin?: 'guide',
371
395
  ): MessageSegment[] {
372
396
  const existingIndex = this.segments.findIndex(
373
397
  (s): s is ApprovalBatchSegment => s.type === 'approval_batch' && s.data.approvalRequestId === approvalRequestId,
@@ -376,6 +400,9 @@ export class MessageSegmentAccumulator {
376
400
  if (existingIndex !== -1) {
377
401
  const existing = this.segments[existingIndex] as ApprovalBatchSegment;
378
402
  const mergedExecutions = executions ?? existing.data.executions;
403
+ // An upsert must not strip the marker: the flip to `approved` comes from
404
+ // a later event that carries no origin of its own.
405
+ const mergedOrigin = origin ?? existing.data.origin;
379
406
  this.segments[existingIndex] = {
380
407
  ...existing,
381
408
  data: {
@@ -383,6 +410,7 @@ export class MessageSegmentAccumulator {
383
410
  approvalType,
384
411
  toolCalls,
385
412
  ...(mergedExecutions ? { executions: mergedExecutions } : {}),
413
+ ...(mergedOrigin ? { origin: mergedOrigin } : {}),
386
414
  },
387
415
  status,
388
416
  resolvedByName: resolvedByName ?? existing.resolvedByName,
@@ -399,6 +427,7 @@ export class MessageSegmentAccumulator {
399
427
  approvalType,
400
428
  toolCalls,
401
429
  ...(executions ? { executions } : {}),
430
+ ...(origin ? { origin } : {}),
402
431
  },
403
432
  status,
404
433
  resolvedByName,
@@ -656,6 +685,9 @@ export class MessageSegmentAccumulator {
656
685
  case 'thinking':
657
686
  if (segment.text) this.appendThinking(segment.text);
658
687
  break;
688
+ case 'guide':
689
+ if (segment.text) this.appendGuide(segment.text);
690
+ break;
659
691
  case 'ask':
660
692
  this.addAsk(segment.question, segment.options);
661
693
  break;
@@ -671,6 +703,7 @@ export class MessageSegmentAccumulator {
671
703
  data.approvalType || '',
672
704
  status,
673
705
  data.fields,
706
+ data.origin,
674
707
  );
675
708
  break;
676
709
  }
@@ -683,6 +716,7 @@ export class MessageSegmentAccumulator {
683
716
  status,
684
717
  data.executions,
685
718
  resolvedByName,
719
+ data.origin,
686
720
  );
687
721
  break;
688
722
  }
@@ -33,7 +33,7 @@ import {
33
33
  } from '../../../chat-protocol/events';
34
34
  // One normalizer for ask rows, shared with the live decoder — history and the
35
35
  // stream must agree on which options are usable.
36
- import { normalizeAskOptions } from '../../../chat-protocol/nats-decoder';
36
+ import { guideFrameEvent, normalizeAskOptions } from '../../../chat-protocol/nats-decoder';
37
37
  import { applyApprovalStatusToSegment } from '../stream/message-mutations';
38
38
  import {
39
39
  MESSAGE_TYPE,
@@ -47,7 +47,7 @@ import {
47
47
  type MessageData,
48
48
  type MessageOwner,
49
49
  } from '../types';
50
- import { approvalDisplaysInline } from './approval-display';
50
+ import { approvalDisplaysInline, guideApprovalOrigin } from './approval-display';
51
51
  import { type MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator';
52
52
  import { getCommandText } from './tool-call-helpers';
53
53
 
@@ -121,6 +121,22 @@ export function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent
121
121
  }
122
122
  return null;
123
123
 
124
+ // Two persisted shapes, mirroring the live `GUIDE` chunk: the answer body
125
+ // (`text`) and a Product Guide frame the agent re-streamed (`payload`,
126
+ // persisted so a card survives a reload). Decoded through the SAME
127
+ // `guideFrameEvent` the live path uses — a second mapping here would let
128
+ // history and realtime disagree about the same bytes.
129
+ case MESSAGE_TYPE.GUIDE: {
130
+ if ('text' in data && data.text) {
131
+ return { type: 'guide-delta', text: data.text };
132
+ }
133
+ const payload = 'payload' in data ? data.payload : undefined;
134
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
135
+ return guideFrameEvent(payload);
136
+ }
137
+ return null;
138
+ }
139
+
124
140
  // Same completeness gate as the live decoder (`decodeNatsChunk`): a
125
141
  // persisted row without a question or without options is not a card the
126
142
  // user can answer, so it replays as nothing rather than as empty chrome.
@@ -381,6 +397,10 @@ function applyHistoryEvent(
381
397
  accumulator.appendThinking(event.text);
382
398
  break;
383
399
 
400
+ case 'guide-delta':
401
+ accumulator.appendGuide(event.text);
402
+ break;
403
+
384
404
  // Mirror of the live path: the intro sentence replays as answer text in
385
405
  // front of the card, so a reloaded thread reads exactly like the stream did.
386
406
  case 'ask':
@@ -397,13 +417,23 @@ function applyHistoryEvent(
397
417
  const toolCalls = event.toolCalls;
398
418
  const isBatch = !!toolCalls && toolCalls.length > 0;
399
419
  // Same rule the live kernels use — a card must not change where it
400
- // renders just because the page was reloaded and it came back through
401
- // history instead of the stream.
402
- if (approvalDisplaysInline(approvalType, displayApprovalTypes)) {
420
+ // renders (or which backend its buttons hit) just because the page was
421
+ // reloaded and it came back through history instead of the stream.
422
+ const guideOrigin = guideApprovalOrigin(event);
423
+
424
+ if (approvalDisplaysInline(event, approvalType, displayApprovalTypes)) {
403
425
  if (isBatch) {
404
426
  const status = (approvalStatuses[event.requestId] as ChatApprovalStatus) || 'pending';
405
427
  if (batchApprovalsEnabled) {
406
- accumulator.addApprovalBatch(event.requestId, approvalType, toolCalls, status, undefined, undefined);
428
+ accumulator.addApprovalBatch(
429
+ event.requestId,
430
+ approvalType,
431
+ toolCalls,
432
+ status,
433
+ undefined,
434
+ undefined,
435
+ guideOrigin,
436
+ );
407
437
  } else {
408
438
  // Flag OFF — unfold batch into N legacy approval cards (same id).
409
439
  for (const call of toolCalls) {
@@ -415,6 +445,7 @@ function applyHistoryEvent(
415
445
  approvalType,
416
446
  status,
417
447
  undefined,
448
+ guideOrigin,
418
449
  );
419
450
  }
420
451
  }
@@ -428,7 +459,12 @@ function applyHistoryEvent(
428
459
  // and honor `approvalStatuses`.
429
460
  const resolvedStatus = approvalStatuses[event.requestId] as ChatApprovalStatus | undefined;
430
461
  const isResolved = resolvedStatus === 'approved' || resolvedStatus === 'rejected';
431
- if (isResolved) {
462
+ // A guide card is added inline even while pending. The tracked path
463
+ // below ends in `flushPendingApprovals`, whose segments the consumer
464
+ // lifts into a sticky footer — that is the treatment for the
465
+ // consumer's OWN approvals, and it is not how the hub's chat renders
466
+ // a proposal (nor where its preamble expects it).
467
+ if (guideOrigin || isResolved) {
432
468
  accumulator.addApprovalRequest(
433
469
  event.requestId,
434
470
  event.command || '',
@@ -436,6 +472,7 @@ function applyHistoryEvent(
436
472
  approvalType,
437
473
  resolvedStatus ?? 'pending',
438
474
  event.fields,
475
+ guideOrigin,
439
476
  );
440
477
  } else {
441
478
  accumulator.trackApprovalRequest(event.requestId, {
@@ -11,7 +11,7 @@
11
11
  * (`title` / `url` / `targetPlatform` / `sourceRepo`). `resolveSourceRowCTA`
12
12
  * then routes that `externalUrl` through the host's `composeContentUrl`
13
13
  * seam and the card is clickable.
14
- * - **Mingo / NATS transport** (`messageData: [{ type: 'TEXT', text }]`)
14
+ * - **Mingo / NATS transport** (`messageData: [{ type: 'GUIDE' | 'TEXT', text }]`)
15
15
  * — the body carries bare `[card://type:id]` markers and NOTHING else.
16
16
  * `chat-message-enhanced.tsx` synthesizes a minimal `{ type, id, title: id,
17
17
  * url: null }` ref so fetch-mode cards can still self-fetch their row, but
@@ -15,6 +15,7 @@ Kanban-style drag-and-drop board built on Pragmatic drag and drop, managing mult
15
15
  - **Nothing moves during a drag** — The dragged card stays put and fades; the landing place is a line. No margins open, no slots close, so the lane's height cannot wander and an edge flip costs one span instead of a relayout plus a card render
16
16
  - **One aim, one preview** — Where the drop points is resolved once, in the board's monitor, and handed down (`drop-aim.ts`). Each card used to work it out from its own hover events, which only ever answers for a card the pointer is physically over — so the preview vanished the moment the pointer left the lanes, even though the drop would still have landed. The board's answer is compared before it is stored, so a pointer moving inside one card does not re-render anything
17
17
  - **Cross-column guards** — `dropDisabled` and `allowedFromColumns` are enforced by each target's `canDrop`, so an invalid drop is never accepted and nothing has to be reverted
18
+ - **No DnD on touch** — `useDragAndDropEnabled()` gates the whole board: on touch/narrow viewports (`(pointer: fine) and (hover: hover) and (min-width: 800px)` fails) every column is forced `dragDisabled`/`dropDisabled`, so nothing registers a draggable or a target — the HTML5 drag events Pragmatic builds on never fire under touch anyway. Card moves there go through the ticket itself, not the board
18
19
  - **Optimistic view** — The board renders `applyPendingMove(columns, pendingMove)` from the drop until the host's answer arrives (`pending-move.ts`), so the move is on screen instantly and the host's data replaces an identical picture. "Landed" is judged per kind of move: across lanes, arriving in the new lane once the old one has let go (a server may re-sort the lane, so the exact index would never come); within one lane, the exact slot. Weaker tests (a fresh `columns` array, the card leaving its old index) fire on a lane's own poll and snap the card back mid-flight. A 2s timeout is the backstop for a host that never applies the change
19
20
  - **Dragged card** — The card under the pointer is the browser's own native drag image (a styled DOM clone via `setCustomNativeDragPreview`), so the browser moves it — over its own chrome included — and carrying a card re-renders nothing and cannot freeze at the viewport edge. On drop, a portal copy takes over for one 160ms FLIP glide from the release point into the slot the move just opened, then hands over to the real card (hidden under it via `LandingCardContext` for exactly that long)
20
21
  - **Keyboard move** — Space on a focused card lifts it; the arrows walk it (up/down through slots, left/right through lanes, skipping any that will not take it); space or enter drops it; escape puts it back. Lift-and-place rather than one-press-one-move, because on a board every press would otherwise be its own write to the server. The lifted card feeds the same aim a pointer drag does, and the drop line is scrolled into view on every step — arrow keys bring no auto-scroll of their own. Keys are handled on the board, not the card: once a card is lifted they belong to the move rather than to whatever holds focus. Rules live in `aim.ts`, pure and unit-tested
@@ -5,6 +5,7 @@ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/ad
5
5
  import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled';
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
7
7
  import { createPortal } from 'react-dom';
8
+ import { useDragAndDropEnabled } from '../../../hooks/ui/use-drag-and-drop-enabled';
8
9
  import { useHorizontalScrollbar } from '../../../hooks/ui/use-horizontal-scrollbar';
9
10
  import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect';
10
11
  import { autoScrollBothWays } from '../../../utils/auto-scroll-ancestors';
@@ -169,7 +170,16 @@ export function Board({
169
170
  // Publishing it from an effect instead committed one whole frame with the card
170
171
  // still held in its pending position after the host had already caught up.
171
172
  const pendingMove = reportedMove && !hasMoveSettled(columns, reportedMove) ? reportedMove : null;
172
- const view = useMemo(() => (pendingMove ? applyPendingMove(columns, pendingMove) : columns), [columns, pendingMove]);
173
+ // On touch/narrow viewports no card is draggable and no lane is a drop
174
+ // target: the HTML5 drag events Pragmatic builds on never fire under touch,
175
+ // so instead of shipping a drag that half-works, none is initialized at all.
176
+ // Card moves there go through the ticket itself (its dialog), not the board.
177
+ const dragAndDropEnabled = useDragAndDropEnabled();
178
+ const view = useMemo(() => {
179
+ const base = pendingMove ? applyPendingMove(columns, pendingMove) : columns;
180
+ if (dragAndDropEnabled) return base;
181
+ return base.map(column => ({ ...column, dragDisabled: true, dropDisabled: true }));
182
+ }, [columns, pendingMove, dragAndDropEnabled]);
173
183
  // EVERYTHING downstream reads `view`, never `columns`: it is what is on
174
184
  // screen, so it is also what a drag, a keyboard move and a drop are about.
175
185
  // `columns` is only ever consulted to ask whether the host has caught up.
@@ -1,13 +1,14 @@
1
1
  <!-- source-hash: manual -->
2
2
  A vertical list whose items are reordered by dragging a handle, built on Pragmatic drag and drop.
3
+ **The one sortable-list implementation in the stack** — every ordered list (schedule scripts,
4
+ ticket statuses, AI quick actions via `TicketStatusConfigList`) runs on it; `@dnd-kit` is gone.
3
5
 
4
- > **Currently unused.** It was written for the ordered lists — schedule scripts, ticket statuses,
5
- > AI quick actions — and those have since gone back to `@dnd-kit` (`TicketStatusConfigList`), whose
6
- > pointer sensors work under touch where the HTML5 drag-and-drop API this is built on does not.
7
- > Kept, not deleted: it is a complete and general implementation, and the ticket board next door
8
- > still runs on the same library. Reach for `TicketStatusConfigList` for a new ordered list; reach
9
- > for this one only if you specifically need what it does differently — slot geometry with
10
- > hysteresis, a FLIP drop animation, and auto-scroll through every scrollable ancestor.
6
+ The HTML5 drag-and-drop API this is built on does not fire under touch — which is why those lists
7
+ once went back to `@dnd-kit`. The answer now is not a second library: on touch/narrow viewports
8
+ (`useDragAndDropEnabled()` is `false`) **no drag is initialized at all**, and each row renders an
9
+ explicit up/down `SortableMoveButtons` pair instead of its handle. Explicit buttons because on
10
+ touch nothing else survives contact with the page: a drag fights scrolling, long-press fights the
11
+ context menu.
11
12
 
12
13
  Deliberately **not** the ticket board, which moves cards between columns and needs a different
13
14
  model.
@@ -17,21 +18,26 @@ model.
17
18
  | Export | Description |
18
19
  |---|---|
19
20
  | `SortableList` | The container. Owns the geometry, the drag monitor and the live region |
20
- | `useSortableItem` | Registers one item: gives back `itemRef`, `dragHandleProps` and `isDragging` |
21
+ | `useSortableItem` | Registers one item: gives back `itemRef`, `dragHandleProps`, `isDragging` and `dragAndDropEnabled` |
22
+ | `SortableMoveButtons` | The touch fallback: a joined up/down pair, disabled at the ends, announcing through the list's live region |
21
23
  | `SLOT_STICKINESS` | The dead band around a slot boundary, as a share of one slot's travel |
22
24
 
23
25
  ## Usage
24
26
 
25
27
  ```tsx
26
28
  <SortableList onReorder={move} getItemLabel={i => rows[i].name} className="flex flex-col gap-6">
27
- {rows.map(row => <Row key={row.id} row={row} />)}
29
+ {rows.map((row, i) => <Row key={row.id} row={row} index={i} count={rows.length} />)}
28
30
  </SortableList>
29
31
 
30
- function Row({ row }) {
31
- const { itemRef, dragHandleProps, isDragging } = useSortableItem()
32
+ function Row({ row, index, count }) {
33
+ const { itemRef, dragHandleProps, isDragging, dragAndDropEnabled } = useSortableItem()
32
34
  return (
33
35
  <div ref={itemRef} className={isDragging ? 'border-ods-accent shadow-lg' : 'border-ods-border'}>
34
- <button type="button" {...dragHandleProps} aria-label="Reorder…"><DraggerIcon /></button>
36
+ {dragAndDropEnabled ? (
37
+ <button type="button" {...dragHandleProps} aria-label="Reorder…"><DraggerIcon /></button>
38
+ ) : (
39
+ <SortableMoveButtons index={index} count={count} label={row.name} />
40
+ )}
35
41
  …
36
42
  </div>
37
43
  )
@@ -76,6 +82,11 @@ function Row({ row }) {
76
82
  moves the list and the pointer together and nothing goes stale.
77
83
  - **Keyboard.** Pragmatic drag and drop ships none, so `dragHandleProps` carries Arrow Up / Arrow
78
84
  Down — one press, one move. The list announces every move through its own live region.
85
+ - **Input gating.** `useDragAndDropEnabled()` — `(pointer: fine) and (hover: hover) and
86
+ (min-width: 800px)` — decides whether any drag exists. `false` (touch, narrow windows, and SSR /
87
+ first render) skips every Pragmatic registration; `SortableMoveButtons` go through the same
88
+ `reorder` as a drop, so the announcement and the `onReorder` contract are identical on every
89
+ input. The consumer `disabled` prop still switches everything off, buttons included.
79
90
 
80
91
  ## Source
81
92
 
@@ -1,3 +1,4 @@
1
1
  export { SLOT_STICKINESS } from './slot-geometry';
2
2
  export { SortableList, type SortableListProps } from './sortable-list';
3
- export { type SortableItem, useSortableItem } from './use-sortable-item';
3
+ export { SortableMoveButtons, type SortableMoveButtonsProps } from './sortable-move-buttons';
4
+ export { type SortableDragHandleProps, type SortableItem, useSortableItem } from './use-sortable-item';
@@ -14,6 +14,7 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from 'react';
17
+ import { useDragAndDropEnabled } from '../../../hooks/ui/use-drag-and-drop-enabled';
17
18
  import { useIsomorphicLayoutEffect } from '../../../hooks/ui/use-isomorphic-layout-effect';
18
19
  import { autoScrollAncestors, autoScrollBothWays } from '../../../utils/auto-scroll-ancestors';
19
20
  import { type DragSlots, measureSlots, resolveSlot, shiftFor } from './slot-geometry';
@@ -30,8 +31,15 @@ const EASING = 'cubic-bezier(0.2, 0, 0, 1)';
30
31
  interface SortableListContextValue {
31
32
  /** Distinguishes this list's drags from any other list's on the page. */
32
33
  dragType: string;
34
+ /** The consumer's `disabled` prop: everything off, move buttons included. */
33
35
  disabled: boolean;
34
- /** Reorders the list. Used by the drop AND by a handle's arrow keys. */
36
+ /**
37
+ * The input-type gate (`useDragAndDropEnabled`): `false` on touch/narrow
38
+ * viewports, where no drag is registered and rows render explicit
39
+ * `SortableMoveButtons` instead of a handle.
40
+ */
41
+ dragAndDropEnabled: boolean;
42
+ /** Reorders the list. Used by the drop, a handle's arrow keys AND the move buttons. */
35
43
  reorder: (from: number, to: number) => void;
36
44
  /** Every registered item, in DOM order. */
37
45
  readItems: () => HTMLElement[];
@@ -82,6 +90,9 @@ export interface SortableListProps {
82
90
  */
83
91
  export function SortableList({ onReorder, getItemLabel, disabled = false, className, children }: SortableListProps) {
84
92
  const listRef = useRef<HTMLDivElement>(null);
93
+ // False on touch/narrow viewports: no drag is initialized anywhere in the
94
+ // list, and rows fall back to explicit move buttons.
95
+ const dragAndDropEnabled = useDragAndDropEnabled();
85
96
  // Stable across renders and never rendered, so there is no hydration to
86
97
  // mismatch — unlike keying a drag on a random per-item id.
87
98
  const dragType = useId();
@@ -126,7 +137,7 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
126
137
 
127
138
  useEffect(() => {
128
139
  const list = listRef.current;
129
- if (!list) return undefined;
140
+ if (!list || disabled || !dragAndDropEnabled) return undefined;
130
141
 
131
142
  /** Alive for one drag; this effect body is its whole scope. */
132
143
  let drag: (DragSlots & { items: HTMLElement[]; to: number; startY: number }) | null = null;
@@ -218,7 +229,7 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
218
229
  },
219
230
  }),
220
231
  );
221
- }, [dragType, readItems, reorder]);
232
+ }, [dragType, disabled, dragAndDropEnabled, readItems, reorder]);
222
233
 
223
234
  // Runs after the commit that carries BOTH the reorder and the tick above, so
224
235
  // the DOM is already in its final order — which is why `onReorder` never has
@@ -267,8 +278,8 @@ export function SortableList({ onReorder, getItemLabel, disabled = false, classN
267
278
  );
268
279
 
269
280
  const context = useMemo<SortableListContextValue>(
270
- () => ({ dragType, disabled, reorder, readItems }),
271
- [dragType, disabled, reorder, readItems],
281
+ () => ({ dragType, disabled, dragAndDropEnabled, reorder, readItems }),
282
+ [dragType, disabled, dragAndDropEnabled, reorder, readItems],
272
283
  );
273
284
 
274
285
  return (
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import { cn } from '../../../utils/cn';
5
+ import { Arrow01DownIcon } from '../../icons-v2-generated/arrows/arrow-01-down-icon';
6
+ import { Arrow01UpIcon } from '../../icons-v2-generated/arrows/arrow-01-up-icon';
7
+ import { Button } from '../../ui/button';
8
+ import { useSortableListContext } from './sortable-list';
9
+
10
+ export interface SortableMoveButtonsProps {
11
+ /** The row's current position — the caller renders the rows, so it knows. */
12
+ index: number;
13
+ /** How many rows the list has; the down button is disabled on the last one. */
14
+ count: number;
15
+ /** Names the row in the buttons' accessible labels, e.g. "On Hold". */
16
+ label?: string;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * The touch replacement for a drag handle: an up/down pair rendered by a row of
22
+ * a {@link SortableList} when `dragAndDropEnabled` is `false` (see
23
+ * `useSortableItem`). Explicit buttons because on touch nothing else survives
24
+ * contact with the page: a drag fights scrolling, long-press fights the context
25
+ * menu, and the HTML5 drag events behind Pragmatic never fire at all.
26
+ *
27
+ * Moves go through the list's `reorder`, so they hit the same `onReorder`
28
+ * callback and the same live region as a drop or a handle's arrow keys.
29
+ *
30
+ * One joined pair per the design: outer corners rounded, a single hairline
31
+ * between the halves, each half disabled at its end of the list.
32
+ */
33
+ export function SortableMoveButtons({ index, count, label, className }: SortableMoveButtonsProps) {
34
+ const { disabled, reorder } = useSortableListContext();
35
+ const upRef = useRef<HTMLButtonElement>(null);
36
+ const downRef = useRef<HTMLButtonElement>(null);
37
+
38
+ const move = (step: -1 | 1) => {
39
+ const to = index + step;
40
+ if (to < 0 || to >= count) return;
41
+ reorder(index, to);
42
+ // The row this pair sits in keeps its DOM nodes, but the pressed half may
43
+ // come back disabled (the row reached an end) — hand focus to the other
44
+ // half so a keyboard user is not dropped to the page body.
45
+ requestAnimationFrame(() => {
46
+ if (step === -1 && to === 0) downRef.current?.focus();
47
+ if (step === 1 && to === count - 1) upRef.current?.focus();
48
+ });
49
+ };
50
+
51
+ const subject = label ?? 'item';
52
+
53
+ return (
54
+ <div className={cn('inline-flex shrink-0', className)}>
55
+ <Button
56
+ ref={upRef}
57
+ type="button"
58
+ variant="outline"
59
+ size="icon"
60
+ aria-label={`Move ${subject} up`}
61
+ disabled={disabled || index <= 0}
62
+ onClick={() => move(-1)}
63
+ className="rounded-r-none border-r-0"
64
+ >
65
+ <Arrow01UpIcon />
66
+ </Button>
67
+ <Button
68
+ ref={downRef}
69
+ type="button"
70
+ variant="outline"
71
+ size="icon"
72
+ aria-label={`Move ${subject} down`}
73
+ disabled={disabled || index >= count - 1}
74
+ onClick={() => move(1)}
75
+ className="rounded-l-none"
76
+ >
77
+ <Arrow01DownIcon />
78
+ </Button>
79
+ </div>
80
+ );
81
+ }